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.
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
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.
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.
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.
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.
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) +1iflen(points) < needed:return0.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)returnfloat(min(scores))
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 inzip(step.message.tool_calls, step.results, strict=True):ifnot result.ok or call.name != design.tool:continueiflen(found) >= design.budget:returntuple(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])ifnot math.isfinite(value):continue seen.add(key) found.append(Measurement(arguments, value))returntuple(found)
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) +1iflen(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 )assertlen(fits) ==2 failed = [ f.model.namefor f in fitsifany("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))
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:
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}")
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.
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.
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, )
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
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 packageuv sync --extra openai# in this shell only: the key stays out of code and filesexportOPENAI_API_KEY="..."uv run --extra openai python -m pbc.agents.design_lab --rig fulluv 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}")
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}")
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.
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 openaiuv sync --locked--no-dev--extra openaiexportOPENAI_API_KEY="..."# this shell onlyuv run --locked--no-dev--extra openai python -m pbc.agents.design_lab