Fixtures — what a run is given, and what it has to recover¶
Component: biopb-mcp — _tests/agentbench/_fixture.py (the vocabulary),
_tests/agentbench/test_fixture_protocol.py (its own tests),
_tests/agentbench/test_fixture_tree.py (the -m fixtures check),
biopb-mcp/tools/author_*_fixture.py (authoring a curated case's data).
Related: ../src/biopb_mcp/_tests/bench/README.md — the benchmark written in this
vocabulary and how to run it.
One runner puts a model in front of a real biopb session and scores what
comes back, whether the case is a claim about a skill or about a piece of
work. Every case hands a verifier a Fixture and an Attempt and reads
back an Outcome; nothing in this layer knows what drift is or what a
landmark is.
A case owns one fixture¶
A case is non-decomposable. Task, persona, fixture, verifier and
tolerances are one artifact, and where the pixels come from — a procedure
or a file on disk — is decided when the case is written, never resolved at
run time. Case.fixture is a single FixtureSpec: no fallback, no
precedence, no substitution. On a machine that cannot produce it, the case
does not run and says why — the same discipline as a missing API key, never
a pass. $BIOPB_FIXTURES is a root path, not a policy switch: it says
where a curated case finds its data, never which fixture a case runs.
Substituting the data changes the truth, the achievable accuracy, and can
invert the conclusion: align-stack-by-features's procedural fixture
(every object an identical isotropic Gaussian) ranked two method families
in the opposite order from real tissue — 1 cold run in 9 chose
descriptor matching on synthetic content, against 2 in 3 on real sections;
its tolerance was calibrated to 3.0 px where the reference scores 0.56 px
synthetic and 3.69 px real. A skill worth covering both ways is therefore
two cases, each its own case_id, tolerances and expectations — what
makes (namespace, case_id) key the artifacts, the reports and the tree.
The vocabulary¶
Fixture(provenance, data, truth, tolerance, about, citation, kind, skill_id, case_id)
Attempt(subject, arrays, notes)
Metric(name, value, limit, unit, unavailable)
Outcome(fixture, attempt, metrics, detail)
data and truth are separate mappings, not one object with optional
fields — a truth key appearing in data is the one leak the whole layer
depends on not happening, and a test can assert it without knowing the
skill. kind, skill_id and case_id are stamped by the spec from the
owning case; anything a builder sets is overwritten, so there is exactly
one place a case's name can drift from.
Truth is data, not a formula. A synthetic fixture knows the answer because it constructed it; a curated one knows whatever a human annotated. Both hand the verifier a mapping, so one verifier serves either kind.
A metric that cannot be computed is unavailable, never passing.
Metric.value is None means this run, against this fixture's truth, does
not support this measurement, and Outcome.passed is false when nothing
was scored — covering both a curated fixture whose truth doesn't support a
measurement and an agent that left nothing behind or bound a name to the
wrong shape. Verifiers read a run's leavings through read_array /
read_scalar, which return (value, why not) rather than raising, since an
agent binds a name to the wrong thing about as often as the right one.
Where the pixels come from¶
class FixtureSpec(Protocol):
kind: Kind # synthetic | curated
def available(self, skill_id, case_id) -> tuple[bool, str]: ...
def build(self, skill_id, case_id) -> Fixture: ...
Two implementations, no ordering between them:
Procedural(builder) |
OnDisk(tolerance=...) |
|
|---|---|---|
kind |
synthetic |
curated |
| Pixels | generated from a seed at run time | read from $BIOPB_FIXTURES |
| Truth | exact by construction | whatever was annotated or applied |
| Availability | always | the tree, the manifest entry, and a reader for every file |
build takes the owning case's identity rather than the case itself, so
this module imports nothing from the engines above it. Where a second
derivation is cheap, a procedural builder asserts the two agree before
handing the fixture over (segmentation-qc-metrics against
plugin:segmentation_qc, calibrated-measurements against
regionprops(spacing=)) — a fixture whose truth is wrong makes every run
scored against it meaningless, so that fails at build time. OnDisk's
tolerance lives on the spec, not in the tree, so a machine's copy of
the data cannot re-tune what counts as a pass.
What real data costs is truth¶
A curated movie can carry a trajectory someone measured off a bead, but not
the un-drifted reference image — no such acquisition exists. Two ways to
close that, per case: the data already carries truth (a segmentation
annotation, a measured trajectory) and the case uses it directly; or a tool
under biopb-mcp/tools/ perturbs a real acquisition once, at authoring
time, recording the transformation in the manifest's provenance
(tools/author_align_channels_fixture.py: a real confocal field, a fixed
affine-plus-sinusoid warp, landmarks sampled from the real nuclei, probe
points whose correspondence is known because the warp is invertible).
The run only ever reads. The perturbation is a build step whose output
is reviewed data; kind stays curated, and there is no third provenance
literal. Nothing here validates the science of an annotation — that
review belongs in the manifest's provenance, since a synthetic seed
doesn't need it.
Handles, not arrays¶
A curated case's truth can be a label volume the size of the acquisition, so a fixture's values may be refs rather than repacked arrays:
class ArrayRef(Protocol):
shape: tuple[int, ...]
dtype: np.dtype
def __array__(self, dtype=None, copy=None) -> np.ndarray: ...
def dask(self, chunks="auto") -> "dask.array.Array": ...
Every verifier already reads through np.asarray, so deferring the read
costs no verifier a line.
NpzRef(path, key)— one array inside an archive; shape and dtype come from the member's own header.FileRef(path)— a whole file as one array, over a small reader registry:.npy(memory-mapped, so a truth volume larger than the test process is addressable),.tif/.tiff,.nii/.nii.gz. The non-mmap readers defer the read but not the residency.
The registry is small and explicit — a fixture tree is reviewed data, so
its formats are a decision, not whatever the machine happens to import.
ref_missing() answers "can this machine open this file" without touching
it, so an unreadable format reports as an availability fact rather than a
crash mid-run. .npz is deliberately not in the registry, since an
archive holds many arrays: a case's layout maps key → filename
({"stack": "arrays.npz"} resolves to the member named stack), so one
archive backs several keys with no new syntax.
The tree¶
$BIOPB_FIXTURES/
├── manifest.json # what this machine has, and whose it is
└── <namespace>/<case_id>/
└── case.json # the data/truth partition, and nothing else
<namespace> is the skill id for a case that names one, and the literal
tasks for one that doesn't — Case.namespace, also the first half of the
case's label and artifact path.
{
"about": "one ACDC patient, slices re-placed independently",
"data": {"stack": "patient101_frame01.nii.gz"},
"truth": {"labels": "patient101_frame01_gt.nii.gz"}
}
A key in both mappings is a hard error. Everything about the data lives in the root manifest, so a curated case has exactly one place recording what was acquired:
{
"version": 1,
"fixtures": [
{
"skill": "tasks",
"case_id": "align-channels-from-landmarks",
"provenance": "channel 3 warped by a fixed affine + sinusoid; 18 landmarks …",
"citation": "UConn Health, Yu lab -- 4-channel confocal, 2026-07-16",
"files": {
"data.npz": {
"sha256": "e3b0c442…",
"bytes": 7077888,
"arrays": {"moving": {"shape": [960, 960], "dtype": "float32"}}
}
}
}
]
}
The manifest's key is still skill, the namespace under the layout above.
citation is required for a curated fixture, carried into the report
and the artifact directory rather than left to whoever remembers (ACDC
ships a MANDATORY_CITATION.md). A fixture on disk with no manifest
entry does not run — an acquisition nobody wrote down is not one a
benchmark should score.
Checking the tree, split by cost¶
Shape and dtype, in-band, at build time (_agrees_with_manifest): a
header read, not a pass over the bytes, and a mismatch means the file under
this path is not the file the case was written against.
The SHA, out-of-band, behind the fixtures marker:
Run after syncing a tree, or when a result looks wrong. It walks the manifest, hashes each file, and reports drift — including a fixture present on disk but absent from the manifest. Never part of a benchmark run (hashing multi-gigabyte volumes on a mount costs more than the run it guards, on every sample). Everything skips on a machine with no tree.
Presentation — how a fixture reaches the agent¶
A fixture is not handed to the agent; it is loaded onto a viewer the agent drives, and how is part of the case:
presentation |
what the agent finds | cost |
|---|---|---|
array |
in-memory numpy on a napari layer, client is None |
none |
tensor |
client non-None, viewer.add_tensor(array_id): lazy, pyramided |
a data plane for the run |
Neither is a default or a fallback for the other — the right one is whichever the skill was written against. There is deliberately no third, mmap-backed "lazy but no server" presentation: a local mmap wearing dask's type would measure the skill off its own route while costing a second loading mechanism.
kind decides which viewer.add_* call the harness makes — points is
how a person's clicked correspondences actually reach napari, not
cosmetic. chunks is explicit rather than left to the uploader's default,
because where laziness is the point the chunking is the thing under
test.
The plane runs for the whole benchmark¶
Only tensor cases need one, so it's conditional — no selected case asks,
nothing starts. When one does, the lifetime is the whole run: one server
with its own temp data dir, started once, serving every case and sample.
$BIOPB_TENSOR_URL is exported into the session child's environment and
inherited down to the kernel, but only for a case that actually uploaded
something; an array case still gets the unreachable address and a None
client. Upload is paid once because a case runs a session per sample and an
invocation runs many cases; a per-session plane would re-upload the same
(often large) fixtures every time. It runs --writable, required anyway
since a read-only plane would fail every step that uploads a result. If it
cannot start — or biopb_tensor_server isn't installed, which is normal —
tensor cases skip with that reason and array cases are unaffected. The
developer's own catalog is neither read nor written, since the server gets
a temp data dir of its own. Only Fixture.data is uploaded — truth
never reaches the plane.
The ids arrive in the namespace, sessions isolated by the id¶
An uploaded source is not synced to the catalog: query() cannot
find it and a task prompt cannot name its id, since it's minted at run
time. The harness binds fixture_tensors = {layer name: array_id} in the
kernel namespace as setup, and a tensor-presenting case says so in its
prompt.
The plane outlives an individual session, so the isolation is in the id:
The id an agent sees is a one-way hash of a name it is never told — the
harness uploads each fixture under a per-run random name, so an agent
holding the id cannot construct the name that would let it replace the
data. That is also checked, not just assumed: a corner of each fixture is
fingerprinted at upload and again after each sample, and a change flags the
row fixture-overwritten rather than silently qualifying the number. An
agent's own uploads persist for the rest of the run — affordable, since
they're bounded by the chunk cache and the plane's whole state is a temp
directory discarded at teardown.
Coverage is a warning, per skill¶
A skill declares what it touches in checklist:, so presentation coverage
is computed rather than judged — but the unit is the skill, not the case. A
case presenting array for a skill that also declares dask isn't wrong,
it tests a real branch; it's incomplete, and the fix is another case:
drift-correction declares ['dask', 'tensor'], but every case presents `array`,
so every run has `client is None` — neither the lazy read path nor any
step that uploads a result has been benchmarked
Never a failure — a gate here would punish an honest partial benchmark as hard as a wrong one.
Artifacts¶
Every case emits a number and an artifact: the number says what happened,
the artifact explains it, and in an imaging project the second is usually
what a person needs. write_report(outcome, root) writes summary.json
under root/<subject>/, keyed on (namespace, case_id), so a second case
for one skill writes beside the first. save_png is best-effort — a
missing imageio plugin is swallowed to a .png.error file rather than
failing the run.
Pass a shared vmax for any pair of images meant to be compared — scaling
each independently is the trap, since a corrected-minus-raw difference near
zero everywhere gets stretched to full range and looks exactly like the
uncorrected one.
artifact_root() honours $BIOPB_OUTCOME_DIR and otherwise lands inside
the checkout, beside the source, searched for by marker rather than
counted to by parent depth.