An agent that designs the measurements

A language model chooses where to measure the slowing of a sliding block so that two models of drag can be told apart, within a budget; the build scores the design, fits both models to the readings it recomputes, and types the conclusion only as far as those numbers support it.
Course
Classical mechanics, lesson 22 of 28
Slot
D9, number 9 of the 13 slots of the path Scientific law discovery
Methods
Simulation, LLM agents
Difficulty
Advanced, level 3 of 3
What you’ll learn
  • Say what makes a measurement useful for telling two models apart, and score a design before any reading exists
  • Read a worked run of an agent that chooses its measurements, and the assessment the build recomputed from it
  • Explain why measurements that fit one model better are not yet a result, and what an inconclusive design looks like
Prerequisites
A counterexample hunter, Inverse physics: parameters from observations.
Before you begin
  • Statistics: least squares and the chi-square of residuals
  • Python: functions and dictionaries
Related
none
Previous
Three reviewers for one piece of code
Next
Discovering a force law with controlled probes

With scripts, this page keeps a record in your browser of whether you completed the lesson. Nothing is stored without them.

With scripts, this place suggests what to do next, with the reason, from the progress saved in your browser. Without them, follow the learning path.

A block slides across a table and slows down. Two models of the drag compete. Under linear drag the speed falls exponentially, v = v0 exp(-g t), and a block started twice as fast keeps the same fraction of its speed. Under quadratic drag it falls as v = v0 / (1 + k v0 t), and the faster block loses a larger fraction. Both have one unknown. Measurements can tell them apart, but not every measurement can: a slow block over a short time looks the same under both.

So the question is not only what the numbers say but where to look. This lesson gives a language model the job of choosing where, with a budget of eight readings, and it keeps three things out of the model’s hands. The build recomputes every reading from the arguments the model sent. It scores the design from those arguments alone, before any reading is looked at. And it fits both models itself, so the model’s account of the result is data and not evidence.

Assumptions

  1. The instrument is simulated. It is the quadratic model with a drag constant the agent is not told, plus Gaussian noise of 0.1 m/s. The noise is a function of the arguments, so the same arguments give the same reading and the replay is exact; a new value of repeat gives an independent reading. No block was measured.
  2. Both models have one parameter, and both are tried. A model that is not on the list cannot win. If the true law were neither, the build would say so only if the fits are bad.
  3. The rules come first. The noise, the budget, the chi-square difference of 25 that counts as telling the models apart, and the parameters used to score a design are fixed before any run, as the scientific validation contract requires. Nothing is widened to produce a result.
  4. The budget is part of the experiment. Eight readings with distinct arguments count. A repeated call with the same arguments is the same reading and counts once.
  5. The agent is untrusted, as before. It reaches the instrument only through the allowlist, with bounded arguments, and its text is data.

Scoring a design before measuring

Take a set of (v0, t) pairs. Suppose quadratic drag is true with the parameter the models share at the calibration run: 10 m/s started, 5 m/s left after 2 s, which both models can be made to reproduce. Compute the speeds it would give at the pairs, with no noise, and fit linear drag to them. What is left over, as a chi-square in units of the instrument noise, is how far apart the models stay at those points. Do the same with linear drag true and quadratic drag fitted, and keep the smaller of the two numbers. That is the score of the design.

The score uses no reading. It says what a design could achieve when the noise does not get in the way, and nothing the agent writes can change it. A score below the threshold of 25 means the measurements cannot tell the models apart at this noise level, however they come out.

After the readings, the judge fits both models to the recomputed values with the fitting helper of the inverse-physics lesson, which returns the chi-square, the parameter and its standard error. The outcome has two values.

  • Discriminated. One model fits the readings within the noise (its chi-square is within three standard deviations of its degrees of freedom), its parameter is determined, and the other is worse by at least 25. The statement is a claim of type numerically-verified, scoped to these readings.
  • Inconclusive. Anything else: too few readings, a difference below 25, no model that fits. It is not a result and has no claim type. It is not “the models are equal”, and it does not say that the better-fitting model is right.

The parameter of the favoured model comes with the standard error of its fit. That error holds if the model is the right one: it is the uncertainty of a parameter inside a model, not of the physics.

The bench

  • measure_speed: Start a block of 2 kg on a table at a given speed and read its speed after a given time, with an instrument whose noise is 0.1 m/s. The same arguments give the same reading; a new value of repeat gives an independent reading.
    • initial_speed: speed at the start, in m/s; allowed values 0.5 to 20
    • time: time after the start, in s; allowed values 0.1 to 10
    • repeat: number of the repetition, for an independent reading; allowed values 1 to 3

The agent may call measure_speed with the arguments above, take at most 8 readings, make at most 12 model messages, and write text. It may not do anything the harness of the first agent lesson forbids. Two rigs are put to it. The full rig starts the block at up to 20 m/s. The slow rig has a launcher that cannot go beyond 2 m/s, so its allowlist has a lower bound on the argument. The bound is the rig: the agent cannot choose a measurement that the rig cannot make.

Code

The model and the design are small records. The judge is the function that decides; it reads no text and no stored result.

def design_score(design: ExperimentalDesign, points: Sequence[Mapping]) -> float:
    """How well the design can separate the two models, from its points alone.

    For each model taken as true, with its nominal parameters, the noise-free
    values at ``points`` are fitted by the other model; the chi-square of that
    best fit, in units of ``design.sigma``, is how far apart the models stay
    there. The score is the smaller of the two directions: a design must
    separate the models whichever one is true. It uses no measured value.
    Fewer points than the larger model has parameters score 0.
    """
    needed = max(len(m.parameters) for m in design.models) + 1
    if len(points) < needed:
        return 0.0
    scores = []
    for truth, other in (design.models, design.models[::-1]):
        a, b = _inputs(design, points)
        clean = truth.function(a, b, *design.nominal[truth.name])
        fit = _fit(design, other, points, clean, other.start)
        scores.append(fit.chi2)
    return float(min(scores))

src/pbc/agents/design.py, lines 192 to 211, read from the repository when this page was built.

def measurements(
    design: ExperimentalDesign, transcript: Transcript, allowlist: Allowlist
) -> tuple[Measurement, ...]:
    """The distinct measurements of ``transcript``, run again.

    Each accepted call of the design's tool is validated and run with the
    function of ``allowlist``, not read from the transcript. A call with the
    arguments of an earlier one is the same measurement and counts once. A
    value that is not finite is not evidence (the validation contract: not
    measured is not passed). At most ``design.budget`` count.
    """
    tool = allowlist.tool(design.tool)
    seen: set[tuple] = set()
    found: list[Measurement] = []
    for step in transcript.steps:
        for call, result in zip(step.message.tool_calls, step.results, strict=True):
            if not result.ok or call.name != design.tool:
                continue
            if len(found) >= design.budget:
                return tuple(found)
            try:
                arguments = tool.validate(call.arguments)
            except ToolRejected:
                continue  # the allowlist could not have accepted it
            key = tuple(sorted(arguments.items()))
            if key in seen:
                continue
            value = float(tool.function(**arguments)[design.result])
            if not math.isfinite(value):
                continue
            seen.add(key)
            found.append(Measurement(arguments, value))
    return tuple(found)

src/pbc/agents/design.py, lines 214 to 246, read from the repository when this page was built.

def judge(
    design: ExperimentalDesign, transcript: Transcript, allowlist: Allowlist
) -> Assessment:
    """Score the design and compare the models on the recomputed measurements."""
    found = measurements(design, transcript, allowlist)
    points = [m.arguments for m in found]
    score = design_score(design, points)
    needed = max(len(m.parameters) for m in design.models) + 1
    if len(found) < needed:
        return Assessment(
            design,
            found,
            score,
            None,
            f"{len(found)} measurement(s) cannot tell two models apart: at least"
            f" {needed} are needed to fit them",
        )
    values = np.array([m.value for m in found])
    fits = tuple(
        ModelFit(model, _fit(design, model, points, values, model.start))
        for model in design.models
    )
    assert len(fits) == 2
    failed = [
        f.model.name
        for f in fits
        if any("did not converge" in w for w in f.fit.warnings)
    ]
    if failed:
        return Assessment(
            design,
            found,
            score,
            None,
            "the fit of " + ", ".join(failed) + " did not converge",
        )
    pair = (fits[0], fits[1])
    return Assessment(design, found, score, pair, _reason(design, pair))

src/pbc/agents/design.py, lines 249 to 286, read from the repository when this page was built.

Worked examples

The messages of both runs are authored examples, not recorded model runs: a Claude model wrote them in a Claude Code session, after reading the results the harness computed. Every number in a tool result, a fit and a verdict below is computed by this build now.

The score of three designs

Before any agent runs, the score tells which designs could work. Three sets of four pairs, each scored with no readings at all:

design = RIGS["full"].design

src/pbc/mechanics/experiments/experimental_design_agent.py, line 47, read from the repository when this page was built.

def three_designs():
    """The score of three designs of four readings against the threshold."""
    designs = {
        "one low speed, several times": [(1, 0.5), (1, 1), (1, 2), (1, 3)],
        "several times near the calibration run": [
            (10, 1.5),
            (10, 2),
            (10, 2.5),
            (10, 3),
        ],
        "several start speeds at one time": [(20, 3), (10, 3), (5, 3), (2, 3)],
    }
    for label, pairs in designs.items():
        points = [{"initial_speed": v, "time": t} for v, t in pairs]
        print(f"{label}: score {design_score(design, points):.3g}")
    print(f"threshold: {THRESHOLD:g}")

src/pbc/mechanics/experiments/experimental_design_agent.py, lines 51 to 66, read from the repository when this page was built.

one low speed, several times: score 0.000663
several times near the calibration run: score 25.4
several start speeds at one time: score 472
threshold: 25

The first two designs measure where the models agree, one at a speed too low to bend the curve and one at the point where the models are made to agree. The third varies the start speed, which is where linear drag and quadratic drag disagree about the fraction of speed left.

A rig that can separate the models

    recording, full_run, assessment = replay_rig("full", LESSON)
    slow_recording, slow_run, slow_assessment = replay_rig("slow", LESSON)

src/pbc/mechanics/experiments/experimental_design_agent.py, lines 167 to 168, read from the repository when this page was built.

Authored example, not a recorded model run. The messages below were written by claude-sonnet-5-5 in a Claude Code session on 2026-10-12, after reading the tool results. No model produced them through a provider API. This build did not call a model. It ran every tool call again; the results shown are the ones it computed.

Step 1. Message (authored example):

The two models differ most in how the loss of speed depends on the start speed. Linear drag gives the same fraction v/v0 left after a given time for every start speed. Quadratic drag leaves a smaller fraction for a faster block. So I fix the time at 3 s, where the block has slowed clearly, and vary the start speed over a wide range: 20, 10, 5 and 2 m/s.
  • measure_speed(initial_speed=20, time=3, repeat=1): recomputed by this build: speed = 4.93088
  • measure_speed(initial_speed=10, time=3, repeat=1): recomputed by this build: speed = 4.00396
  • measure_speed(initial_speed=5, time=3, repeat=1): recomputed by this build: speed = 2.77481
  • measure_speed(initial_speed=2, time=3, repeat=1): recomputed by this build: speed = 1.61718

Step 2. Message (authored example):

The fraction left is about 25 per cent at 20 m/s, 40 per cent at 10, 55 per cent at 5 and 81 per cent at 2. Under linear drag these would be one number, and the spread is far larger than the 0.1 m/s of the instrument. Four readings are already enough to separate the models by the criterion. I use the other four to pin the parameter of the quadratic model: a long time for the fast and the slow start, a short time, and one more at 10 m/s.
  • measure_speed(initial_speed=20, time=9, repeat=1): recomputed by this build: speed = 1.98089
  • measure_speed(initial_speed=5, time=9, repeat=1): recomputed by this build: speed = 1.48687
  • measure_speed(initial_speed=20, time=1, repeat=1): recomputed by this build: speed = 9.92761
  • measure_speed(initial_speed=10, time=9, repeat=1): recomputed by this build: speed = 1.87279

Step 3. Message (authored example):

Result of the build's fits, as I read it: the quadratic model fits all eight readings with a chi-square of about 2 for 7 degrees of freedom, and its parameter k comes out near 0.0507 per metre with a standard error of about 0.0007. The linear model gives a chi-square of about 2050. The measurements favour quadratic drag over linear drag by a very large margin. This is a statement about these eight readings of this instrument, not a proof that the drag of a real block is quadratic, and the stated error of k holds only if quadratic drag is the right model.

The model ended the run with a message that requests no tool.

The assessment does not come from the last message:

Verdict: the measurements discriminate, recomputed by this build.

The build recomputed 8 measurement(s) from their arguments and scored the design at 2031.48 (chi-square by which the worse model stays behind the true one on noise-free values at these points; the smaller of the two directions). A difference of 25 counts as telling the models apart.

  • linear drag: chi-square 2051.5 for 7 degrees of freedom; g = 0.485018 ± 0.00489215 (one standard error)
  • quadratic drag: chi-square 1.99661 for 7 degrees of freedom; k = 0.0506587 ± 0.000697757 (one standard error)

Numerically verified (the 8 measurements of this run with an instrument noise of 0.1): The measurements favour quadratic drag: it fits within the noise, and the other model is worse by a chi-square of 2050.

What the agent wrote did not decide the verdict.

The score of the design is far above the threshold, so the outcome did not depend on how the noise fell. The favoured parameter, with its standard error from the fit, is a statement about quadratic drag as a model of the readings. It does not show that a real block follows it, and the build did not measure one.

A rig that cannot

Authored example, not a recorded model run. The messages below were written by claude-sonnet-5-5 in a Claude Code session on 2026-10-12, after reading the tool results. No model produced them through a provider API. This build did not call a model. It ran every tool call again; the results shown are the ones it computed.

Step 1. Message (authored example):

With a launcher limited to 2 m/s the quadratic effect is small: at 2 m/s and 10 s the loss from k v0 t is about one. The models are furthest apart at long times and at the highest speed the rig allows, so I measure there and add a repeat to see the noise: 2 m/s at 10 s twice (repeat 1 and 2), 2 m/s at 5 s, and 1 m/s at 10 s.
  • measure_speed(initial_speed=2, time=10, repeat=1): recomputed by this build: speed = 1.15007
  • measure_speed(initial_speed=2, time=10, repeat=2): recomputed by this build: speed = 0.96676
  • measure_speed(initial_speed=2, time=5, repeat=1): recomputed by this build: speed = 1.44305
  • measure_speed(initial_speed=1, time=10, repeat=1): recomputed by this build: speed = 0.796657

Step 2. Message (authored example):

The readings are 1.15, 0.97, 1.44 and 0.80 m/s. The two readings at 2 m/s and 10 s differ by 0.18 m/s, which is the noise. I spend the other four on the same idea: 0.5 m/s at 10 s, 1.5 m/s at 10 s, 1 m/s at 5 s, and 2 m/s at 2 s as a short one.
  • measure_speed(initial_speed=0.5, time=10, repeat=1): recomputed by this build: speed = 0.30194
  • measure_speed(initial_speed=1.5, time=10, repeat=1): recomputed by this build: speed = 1.06946
  • measure_speed(initial_speed=1, time=5, repeat=1): recomputed by this build: speed = 1.00121
  • measure_speed(initial_speed=2, time=2, repeat=1): recomputed by this build: speed = 1.49309

Step 3. Message (authored example):

The build's fits favour quadratic drag: chi-square about 14 for 7 degrees of freedom against about 28 for linear drag, a difference of about 14, with k near 0.041 per metre and a standard error of about 0.004. A difference of 14 is below the 25 that this lesson counts as telling the models apart, so the measurements do not tell them apart. I would not report a model. The limit is the rig: with starts of at most 2 m/s, eight readings at this noise cannot separate the models, and a launcher that reaches 10 m/s or more would.

The model ended the run with a message that requests no tool.

Verdict: inconclusive. This is not a result about the models and has no claim type.

The build recomputed 8 measurement(s) from their arguments and scored the design at 7.60924 (chi-square by which the worse model stays behind the true one on noise-free values at these points; the smaller of the two directions). A difference of 25 counts as telling the models apart.

  • linear drag: chi-square 27.8404 for 7 degrees of freedom; g = 0.0546999 ± 0.0046112 (one standard error)
  • quadratic drag: chi-square 14.0921 for 7 degrees of freedom; k = 0.04102 ± 0.00429593 (one standard error)

Why: the chi-square of the two models differs by 13.7, less than the 25 that counts as telling them apart. Measurements that do not separate the models do not show that they are equal, and a model that fits is not thereby true.

The agent’s last message says the same thing in its own words, but the verdict would not change if it said the opposite. Quadratic drag fits and linear drag does not, in the sense of the chi-square, and the build still does not report a result: the difference is below the threshold set before the run, and the score of the design says that no eight readings of this rig reach it.

The assessment ignores what the agent says

def with_last_text(transcript, text):
    *earlier, last = transcript.steps
    message = ModelMessage(text, last.message.tool_calls)
    return replace(transcript, steps=(*earlier, Step(message, last.results)))

src/pbc/mechanics/experiments/experimental_design_agent.py, lines 69 to 72, read from the repository when this page was built.

def what_the_agent_says(full_run, slow_run):
    """Exercise 1: the judge on a boasting agent on the slow rig and on a
    silent agent on the full rig."""
    boasting = with_last_text(
        slow_run, "Quadratic drag is confirmed: chi-square 900 against 12."
    )
    silent = with_last_text(full_run, "")
    rig = RIGS["slow"]
    print(
        "boasting agent, slow rig:",
        judge(rig.design, boasting, tools(rig.maximum_speed)).outcome,
    )
    print(
        "silent agent, full rig:",
        judge(RIGS["full"].design, silent, tools()).outcome,
    )

src/pbc/mechanics/experiments/experimental_design_agent.py, lines 75 to 90, read from the repository when this page was built.

boasting agent, slow rig: inconclusive
silent agent, full rig: discriminated

The stored results of a transcript are not read either. The build recomputes each reading from its arguments:

def forged_results(full_run):
    """Exercise 2: the judge on readings the agent forged."""
    forged = replace(
        full_run,
        steps=tuple(
            Step(
                step.message,
                tuple(
                    replace(r, content={"speed": 3.0}) if r.ok else r
                    for r in step.results
                ),
            )
            for step in full_run.steps
        ),
    )
    print("forged results:", judge(RIGS["full"].design, forged, tools()).outcome)

src/pbc/mechanics/experiments/experimental_design_agent.py, lines 93 to 108, read from the repository when this page was built.

forged results: discriminated

What the design bought

The figure compares the two models on the full rig: the speed left after 3 s, as a fraction of the start speed, against the start speed. Linear drag gives a horizontal line. The dots are the readings of the worked run at 3 s.

def draw_design(assessment) -> Figure:
    """The fraction of the start speed left after three seconds, the two
    fitted models and the readings of the campaign."""
    fits = {f.model.name: f.fit for f in assessment.fits}
    speeds = np.linspace(0.5, 20, 100)
    fig, ax = plt.subplots(figsize=(6.4, 3.6))
    ax.plot(
        speeds,
        quadratic(speeds, 3.0, fits["quadratic drag"].value("k")) / speeds,
        label="quadratic drag, fitted",
    )
    ax.plot(
        speeds,
        linear(speeds, 3.0, fits["linear drag"].value("g")) / speeds,
        label="linear drag, fitted",
    )
    for m in assessment.measurements:
        if m.arguments["time"] == 3.0:
            ax.plot(
                m.arguments["initial_speed"],
                m.value / m.arguments["initial_speed"],
                "ko",
                markersize=4,
            )
    ax.set_xlabel("start speed (m/s)")
    ax.set_ylabel("fraction of the start speed left after 3 s")
    ax.set_ylim(0, 1.05)
    ax.legend()
    return fig

src/pbc/mechanics/experiments/experimental_design_agent.py, lines 111 to 139, read from the repository when this page was built.

A chart of the fraction left after 3 seconds against the start speed from 0 to 20 metres per second. The quadratic curve falls from near one at low speed to about a quarter at 20 metres per second. The linear curve is a horizontal line. The four dots lie on the quadratic curve and far from the line at the higher speeds.
Figure 1: simulated Fraction of the start speed left after 3 s against the start speed, for linear drag fitted to the readings of the worked run and quadratic drag fitted to the same readings; dots are the four readings of the run at 3 s.

Run the agent yourself

This needs your own key and costs a few cents. The build does not run it, so the commands are marked.

Not verified. The build of this site did not run or check the material in this box.

# once: install the optional provider package
uv sync --extra openai

# in this shell only: the key stays out of code and files
export OPENAI_API_KEY="..."

uv run --extra openai python -m pbc.agents.design_lab --rig full
uv run --extra openai python -m pbc.agents.design_lab --rig slow

The program prints each message and reading and, at the end, the assessment it computed itself. A maintainer can replace the two authored fixtures of this page with real recorded runs using the same commands and --record.

Limits

  • A discrimination is about the readings and the two models. It says that one of two named laws fits these readings and the other does not. It does not say the block obeys that law, and a third law the list lacks could fit better.
  • The uncertainty is conditional. The standard error of the parameter is the one of the fit, valid when the favoured model is right and the noise is as stated. The noise here is known, because the instrument is simulated.
  • The score depends on the nominal parameters. It says whether a design can separate the models if their parameters are the ones assumed before the run. A different true value changes the number, not the idea.
  • An inconclusive design is not a negative result. It says that these readings could not tell the models apart. A better design might.
  • The agent’s reasoning is authored text. The messages are examples written for the page, not a measurement of how a model designs. Nothing in the build checks whether its reasoning was sound, only the readings it asked for.
  • The examples are not recorded runs. Each fixture says which model wrote its messages and when. They were written in a Claude Code session, not produced through the provider API, and a live run will choose other measurements.

Exercises

Exercise 1. Take the design of the first worked run, four start speeds at 3 s. Add the readings of a fifth start speed, 15 m/s at 3 s, and score the design again. Does the score change much, and why?

Solution to exercise 1
def a_fifth_reading():
    """Exercise 3: the score of the best design with and without a fifth
    reading."""
    base = [(20, 3), (10, 3), (5, 3), (2, 3)]
    for label, pairs in (("four", base), ("five", [*base, (15, 3)])):
        points = [{"initial_speed": v, "time": t} for v, t in pairs]
        print(label, f"{design_score(design, points):.4g}")

src/pbc/mechanics/experiments/experimental_design_agent.py, lines 142 to 148, read from the repository when this page was built.

four 471.9
five 473.6

The score is already well above the threshold with four, and the fifth point lies on the same curves between two points that were there. A design past the threshold gains little from another point of the same kind; a reading that measures the other end of the range, a long time for the slowest block, would say more about the parameter than about the choice of model.

Exercise 2. The threshold of 25 was fixed before the runs. Look at the slow rig again: at what threshold would its assessment become a discrimination, and why is it wrong to choose that threshold now?

Solution to exercise 2
def slow_difference(slow_assessment):
    """Exercise 4: the difference of the two models on the slow rig."""
    print(f"{slow_assessment.difference:.3g}")

src/pbc/mechanics/experiments/experimental_design_agent.py, lines 151 to 153, read from the repository when this page was built.

13.7

Any threshold up to this number would call the slow run a discrimination. A threshold chosen after the readings, to give the answer the page would like, is widened to pass, which the validation contract forbids. The 25 comes from the noise and the degrees of freedom of the problem before any run, and the slow rig simply does not reach it.

Replay this experiment

This page was built from commit 6417e2ff90258bd2d2ba21d217eb7666fd24f680 of the repository.

In a clone at that commit, with the project’s Python packages installed, this command runs the experiment of this page:

uv run --locked --no-dev python -m pbc.mechanics.experiments.experimental_design_agent results
Files, tests, and every command

The files behind it:

With Git and uv installed (see the setup page), run:

git clone https://github.com/taspinar/physics-by-construction.git
cd physics-by-construction
git checkout 6417e2ff90258bd2d2ba21d217eb7666fd24f680
uv sync --locked --no-dev
uv run --locked --no-dev python -m pbc.mechanics.experiments.experimental_design_agent results

The experiment writes every figure of this page into results/, as <label>.png, and every number and table of the page, as the page shows it, into results/results.json. Its numbers match this page to the digits shown, and its figure files are the ones the page shows; digits beyond those can differ between machines, because floating-point libraries differ. Change the code of the experiment and run it again to see what changes.

The experiment replays the recorded run of the agent: it runs every tool call of the recording again and needs no model and no key.

Not verified. The build of this site did not run or check the material in this box.

Optional: the live run. It asks a language model to run the experiment again, with your own account. It needs an OpenAI account and its API key in the variable OPENAI_API_KEY, and the optional dependency openai; a run costs a few cents. The key stays in your shell, never in a file.

# needs: an OpenAI account, its API key, and the extra openai
uv sync --locked --no-dev --extra openai
export OPENAI_API_KEY="..."   # this shell only
uv run --locked --no-dev --extra openai python -m pbc.agents.design_lab