Nullius in verba — take nobody's word for it.
This is the executable plan derived from docs/. The design documents say what to build and why; this says in what order, with what acceptance test, and what changes because of the machine we're actually on.
Status: M0–M35 complete; v5 and v6 landed, v7 registered and not yet run. V6's result does not adjudicate what v6 registered — its treatment arm did not implement the mechanism it names; see M23. The live path is wired but unspent (mock-driven throughout; the first live run awaits an API key). M12's code-generation half is blocked on both a key and Docker. Nothing below is claimed as done until its acceptance criteria are green in CI.
Recon on the build machine (2026-08-30):
| OS | Windows 11 Pro, 28 CPUs |
| Python | 3.12.10 · uv 0.12.7 |
| Node | 24.18.0 |
| Git / GitHub | git 2.55 · gh authenticated as martex-dev |
| Docker / Podman | absent |
| WSL2 | absent |
| PostgreSQL | absent |
| LLM API key | absent |
Four constraints, four decisions. Each is recorded as an ADR in docs/adr/ and each is reversible.
docs/03-data-model.md targets Postgres 16. At MVP scale (~10⁵ events, ~10⁴ result rows) SQLite is entirely sufficient, and it makes the repository's central promise — reproducible from a clean clone — literally true: git clone && uv sync && nullius demo, no services.
Schema is written once in SQLAlchemy; both backends run the same migrations. Postgres-only hardening (row-level security for Replicator blindness) becomes a backend capability rather than an assumption, and where it is unavailable the same invariant is enforced by the role-scoped repository layer plus a query audit log — which is more testable anyway. See ADR-0001.
SandboxBackend is an interface with SubprocessSandbox (default: AST validation, import allowlist, a sys.addaudithook denying sockets/subprocess/out-of-workdir writes, wall-clock kill, psutil memory cap), DockerSandbox (the full hardening set in docs/05-security.md), and room for gVisor later.
The load-bearing point: the MVP executes no LLM-written code at all. Builder-as-compiler means experiment code is our own unit-tested harness. Container isolation is therefore not on the critical path until M12 (code generation). Until then SubprocessSandbox is defence against accidental misbehaviour, which is the real threat, and the README says so plainly rather than implying a security boundary that isn't there. See ADR-0002.
Every layer below the agents is testable with MockProvider. The content-addressed LLM cache doubles as a record/replay fixture store, so once a demo has been recorded it re-runs in CI for free and byte-identically. A key is required only for the first live agent run at the end of M6. See ADR-0005.
This reorders docs/06-roadmap.md. The agents are the easy part and the least certain; the ledger, the custodian and the question bank are what everything else depends on and what makes any agent result meaningful. Building the evaluation before the thing being evaluated is the whole thesis of this project applied to itself.
Dependency order. Each milestone is a PR into main with CI green.
Repository, tooling, CI, package skeleton, CLI entry point, licence, ADR log, docs moved in.
Acceptance
uv sync && uv run pytestgreen from a clean clone on Linux + Windows CI.nullius versionandnullius --helpwork.- ruff + mypy (strict on
src/) clean.
The append-only spine and every entity from docs/03-data-model.md.
- SQLAlchemy models: events, programs, hypotheses, registrations, runs, results, claims, evidence, objections, reviews, positions, decisions, forecasts, policies, costs, sources, datasets, artifacts.
Alembic migrations— deferred to the end of M5, see ADR-0006.- Hash-chained event ledger +
nullius ledger verify. - Content-addressed artifact store (
objects/<ab>/<sha256>). - Role-scoped repository layer — the only write path.
- Invariants as constraints/triggers: no run without a prior locked registration; holdout metrics only from the custodian; no claim without evidence; append-only enforcement.
Acceptance
- Property tests (Hypothesis) prove each invariant cannot be violated through the public API.
- Tamper test: mutating a historical event is detected by
ledger verify. - Round-trip test: state rebuilt by folding the event log equals the read models.
- Role contracts (
RoleContract,AgentTask,AgentResult) exactly asdocs/02-architecture.md§2. - Task queue + worker loop (
SKIP LOCKEDon Postgres; single-writer transaction on SQLite). LLMProvider:AnthropicProvider,MockProvider,ReplayProvider.- Content-addressed response cache keyed on
(provider, model, params, prompt, tool_schemas). - Cost ledger with a versioned price table; hierarchical budget enforcement at dispatch.
- Structured output via Pydantic with one repair retry, then hard fail.
Acceptance
- A trivial role executes end-to-end against
MockProvider. - Replaying a recorded run is byte-identical and costs $0.
- A task exceeding its parent budget is refused at dispatch, and the refusal is an event.
ExperimentSpecschema (the registered object).- Design linter: single pre-declared primary metric, capacity-matched baselines, grouped splits, seed minimum, power for the stated MDE.
- Spec → executable plan compiler (scikit-learn, CPU).
SandboxBackend+SubprocessSandbox+DockerSandbox.- Artifact harvest, telemetry, environment manifest hashing.
Acceptance
- A hand-written spec compiles, runs sandboxed, emits hashed artifacts and telemetry.
- Isolation suite: attempts to open a socket, spawn a subprocess, or write outside the workdir are all denied and logged.
- Two runs with the same seed produce identical
environment_hashand identical metrics.
�
- SCM DSL: causal / spurious / noise features, environments, shift configurations.
- Oracle: true population effects computed by large-sample evaluation, cached and versioned.
- Bank v1 — RQ-001's five configurations plus ten further items, ≥45 % true-zero, ~20 % conditional, ~15 % carrying a planted defect.
- Bank isolation: ground truth lives where no agent view can join it; an isolation test proves it.
Acceptance
nullius bank verifyrecomputes every truth value deterministically from the DGP.nullius bank statsreports null fraction, conditional fraction, planted-defect fraction.- A leak test proves no role-scoped view exposes a
ground_truthcolumn.
- Holdout Custodian as a separate process holding the test splits; preregistered evaluator; per-registration query budget.
- Statistics: seed variance, paired BCa bootstrap, effect sizes, Holm and Benjamini–Hochberg at program level.
- Verdict derivation and the computed confidence rubric.
Acceptance
- Known-answer fixtures for every statistic (values checked against independent references).
- No code path exists by which a non-custodian process produces a holdout metric — proven by test, not inspection.
- Query budget exhaustion is an event and blocks further holdout access.
Roles: Theorist, Designer, Analyst, and a rule-based Director. Plus the Registry and the Forecast Ledger.
Acceptance
- One bank item runs hypothesis → registration → forecasts → build → execute → custody → analysis → claim, with the registration hash provably predating the first run.
- Full provenance: every number in the output resolves to a
run_resultrow. - Recorded with
MockProvider, then the first live run against Anthropic. Cost reported in USD.
Skeptic + detector suite, typed objections with mandatory discriminating tests, Replicator with enforced blindness, Reviewer, defect injector.
Acceptance
- Skeptic recall/precision on injected defects measured and reported; recall > 0.5 to pass.
- Replicator blindness proven by audit log: it never read a row from the original run.
- At least one claim is blocked from promotion by a critical objection, and unblocked only by a discriminating experiment.
Genealogy CTEs, follow-up generation from terminal states, institutional-novelty dedup, cross-item memory.
Acceptance — second-generation hypotheses demonstrably derive from first-generation results; a duplicate hypothesis is caught at intake.
Forecast-derived EIG, the AllocationPolicy interface with random / round-robin / greedy-EIG / Thompson implementations, hierarchical budgets, reserves for replication and null confirmation.
Acceptance — greedy-EIG measurably beats random on cost-per-correct-claim over the bank, or is shown not to. Either result ships.
The result, and it is the second kind. Measured over the twenty-item bank at a $0.03 budget, 400 paired bootstrap resamples over items (ADR-0007 for why the comparison holds the science fixed and varies only selection):
| policy | funded | correct | $/correct | nats/$ |
|---|---|---|---|---|
random/v1 |
4 | 4 | $0.0065 | 780.3 |
round-robin/v1 |
4 | 4 | $0.0065 | 780.2 |
cheapest-first/v1 |
4 | 4 | $0.0065 | 780.4 |
greedy-eig/v1 |
4 | 4 | $0.0065 | 780.4 |
thompson/v1 |
4 | 4 | $0.0065 | 780.3 |
Greedy-EIG against random on correct claims per dollar: +2.54, 95% CI [−38.59, +38.62] — no measurable difference. The spread of expected information gain across items is 0.000000000 nats, and greedy-EIG's figures are identical to the cost-only control's to every digit shown.
Why the information term could not have helped. Under MockProvider every role emits the same forecast for every item, so EIG is constant across the bank and greedy-eig/v1 is cheapest-first/v1. Reporting "EIG does not help" from that setting alone would be a claim about the forecasts dressed up as a claim about the policy — which is what nullius economy sweep exists to separate. Dialling forecast informativeness from nothing to oracle-grade, greedy-EIG never separates from random at any forecast quality, and never beats the cost-only control. Its point estimate against the control rises with forecast quality but the interval spans zero throughout.
An earlier version of this section reported the opposite, and was wrong. It claimed greedy-EIG beat random at +12.51, CI [+0.01, +38.96]. That interval's lower bound was one hundredth of a unit above zero, and it did not survive re-measurement once the seed bug below was fixed. A knife-edge separation reported as a win is exactly the failure this project is built to catch, and it caught it here on its own machinery rather than in review.
The honest limitation. Every bank item costs within 0.1% of every other, so all five policies fund exactly four items and there is almost nothing for an allocator to differentiate on. The bootstrap intervals are correspondingly enormous — roughly ±38 on a quantity whose point estimates are single digits. This design can detect a large allocation effect and would miss a moderate one. Cost heterogeneity across the bank is what M10 needs in order to make this comparison sharp; the honest statement today is no detectable effect at this power, not no effect.
A reproducibility bug the measurement found in the kernel. Seed roots were derived from abs(hash(item.item_id)). Python randomises string hashing per process, so a preregistered seed root differed on every run — changing spec_hash, changing which seeds were drawn, and flipping the verdict of any item near a decision boundary. M3's acceptance claim, that two runs with the same seed give identical metrics, held within a process and failed across them, which is the case "reproducible from a clean clone" actually depends on. Now derived from SHA-256 via seed_for(), pinned by a test that compares subprocesses started with different PYTHONHASHSEED values, plus a control proving the old derivation really was unstable.
What re-running the measurement does and does not reproduce. Experiment seeds are now fixed by the preregistration. The evaluation sample is not: the Custodian derives it from the registration id, so a fresh measurement draws a fresh holdout. That is deliberate — a custody seed derived from the design would let anyone re-register the same spec and shop a fixed evaluation set. Two consecutive full measurements were compared: all twenty verdicts agreed, while every realised effect differed. bank/outcomes.lock.json records one honest draw, not a canonical one.
The economy now governs the institution, rather than sitting beside it. The first cut of M9 shipped an allocator that nothing in the research lifecycle called. ResearchKernel is now split at the seam the economy needs — propose() stops at a locked registration and locked forecasts, which is where expected information gain first becomes computable and just before anything expensive happens, and execute() continues from there. FundingRound puts several questions up, allocates the laboratory's budget across them, and runs only the winners; the rest keep their registrations and forecasts, reach ABANDONED_BUDGET, and leave a decision row naming what beat them. nullius economy round drives it.
That work found the container was wrong. Allocation across bank items had been written inside a single programme, and M8's institutional-novelty guard correctly refused every proposal after the first — a Program is one research question, which is what the guard enforces. So a round now opens one programme per question and allocates one tier up, at the laboratory, which is the institution → program boundary the budget hierarchy already had and nothing had yet used.
Three silent bugs surfaced on the way, all of which reported as science rather than as faults, and all now pinned by tests:
SubprocessSandboxpassed a relative workdir to a child launched inside that workdir, so the path resolved twice and every seed returnedscientific_failure. The first two bank measurements reported 0/20 correct because of it. An execution fault wearing the costume of a research result is the one disguise this project cannot allow.- The write guard compared a resolved allowed-root against an unresolved target, so a workdir reached through a Windows 8.3 short name denied every write an experiment made to its own output directory.
.gitignoreused trailing#comments, which git does not support —objects/,runs/and the rest matched nothing and were never ignored.
Arms B0–B7 from docs/04-evaluation.md, matched on model, compute, seeds and data access. Preregistered protocol committed with a hash before results are collected.
Acceptance — the full ladder runs; verdict accuracy, null accuracy, calibration, FDR, and cost-per-correct-claim reported per arm with bootstrap CIs.
The protocol went in first, in its own commit. benchmark/protocol.lock.json (1d4c76d2…) fixes the arms, the metrics, the bootstrap resample count, alpha, the multiplicity correction, the baseline arm, the map from computed confidence to probability, and the exclusion rules — and it was committed before a single line of the runner existed. The git history is therefore the evidence that the plan predates the results, rather than a docstring claiming it does. nullius benchmark run refuses to start if verify_protocol fails, so a run against a bank that moved after registration cannot be reported as preregistered.
One pipeline, not eight. The arms are switches (Mechanisms) that ResearchKernel reads — custody, preregistration, adversary, replication, memory. Two institutional arms run the same code with different flags. A second implementation for B3 to drift away in is the failure mode this avoids, and the ablation's validity rests entirely on it. Two tests exist purely to prove the switches are connected rather than merely recorded: the same design, same seeds, run with and without the Custodian, must produce different measured effects — because the custodied pass is reading a sample the other never saw — and it must consume no holdout budget when custody is off.
Three code paths, because the ladder declares three. B0 answers no_effect without looking and costs nothing. B1 and B2 ask a model directly with no ledger, registration or execution. B3 upward is the kernel. Giving B1 the institution's machinery would have made it a different arm.
What the mock decides, stated rather than buried. Under MockProvider there is no such thing as "what the model would say" — only what the runner tells it to say. DIRECT_MOCK_VERDICT is supported: the documented failure mode of an unstructured agent asked whether an intervention helps. That makes B1 the mirror of B0, and it means B1 and B2's numbers are a property of that constant, not a measurement. B2 receives the identical answer on its second pass, because a mock that improved on revision would be the runner deciding that iteration helps — which is the question B2 was added to ask. Both arms carry model_dependent, and no mechanism claim rests on them.
A defect this milestone found in M8's memory. recall() was programme-scoped. A Program is one research question, so memory could never cross from one bank item to the next — which would have made B6 and B7 identical by construction and produced an ablation capable only of reporting no difference. Memory is now recalled at lab scope, which is the institution → program boundary the budget hierarchy already had. Programme scope remains the default, because it is the right answer for a single programme reasoning about itself.
A second, still open. The novelty fingerprint covers metric, direction, effect size and statement tokens, but not the dataset. The bank is twenty variants of one question across twenty data-generating processes, so "the same question about different data" is indistinguishable from "the same question again". M9 hit the same boundary and resolved it the same way — one programme per question — and that is what the runner does. The underlying gap in fingerprint() is recorded here rather than patched, because widening it is a change to what novelty means and belongs with a test that says so.
Why every arm gets its own database. The ledger refuses to register the same design twice: re-registering would hide that an experiment was run twice. That is correct, and it is also why two arms of an ablation cannot share a store. Two arms are not two runs of one experiment; they are one experiment run by two different institutions.
Who registers a replication. The Replicator may not register — that authority is the Designer's, and widening it would have traded a real separation for a cosmetic one. A replication is preregistered by the Designer with a fresh seed root and executed by the Replicator, whose reads are filtered to its own runs. Re-executing the identical seeds would test that the code is deterministic, which is already known and is not what replication means.
The ladder ran. benchmark/results.lock.json, protocol 1d4c76d2…, mock provider.
| arm | acc | null | brier | ece | fdr | $/correct | |
|---|---|---|---|---|---|---|---|
| B0 | oracle-null | 0.45 | 1.00 | 0.250 | 0.050 | 0.00 | 0.00000 |
| B1 * | single-shot | 0.20 | 0.00 | 0.200 | 0.200 | 0.45 | 0.00269 |
| B2 * | + loop | 0.20 | 0.00 | 0.200 | 0.200 | 0.45 | 0.00761 |
| B3 | multi-role | 0.90 | 0.89 | 0.283 | 0.417 | 0.00 | 0.00722 |
| B4 | + prereg + custodian | 0.95 | 0.89 | 0.242 | 0.420 | 0.00 | 0.00683 |
| B5 | + Skeptic | 0.95 | 0.89 | 0.302 | 0.467 | 0.00 | 0.00683 |
| B6 | full institution | 0.90 | 0.89 | 0.316 | 0.415 | 0.00 | 0.00732 |
| B7 | full − memory | 0.95 | 1.00 | 0.311 | 0.472 | 0.00 | 0.00693 |
* model-dependent; this run used a mock provider, so B1 and B2 describe the mock.
The one result that survives its own interval. B4 − B0 = +0.50, CI [+0.25, +0.75], p = 0.002. The institution decisively beats a free constant that answers no_effect without looking. Nothing else on the ladder separates.
The registered prediction is upheld, and the margin is one item. Mechanism (B4 − B3) = +0.05, CI [+0.000, +0.150], p = 0.73. Everything else (B6 − B4) = −0.05, CI [−0.150, +0.000], p = 0.70. Both span zero. The primary metric moves in steps of 1/20 = 0.05, so the verdict rests on a single item in each direction. Reported as upheld because that is what the registered rule returns, and reported as uninformative because that is what the arithmetic says. The rule was committed before the numbers and has not been touched since; what changed is that its intervals are now printed beside it.
Three flaws in the preregistered protocol, found only by running it. None is patched, because the protocol is hashed and editing it is the one move preregistration exists to prevent. Each belongs to a v2 registered as a change.
- The adjudication rule is too weak.
mechanism > agentscompares two point estimates and requires neither to separate from zero. On twenty items it can return "upheld" for noise, and on this run it did. A v2 should require the mechanism contrast to exclude zero. - The registered baseline is model-dependent. The protocol names B1 as the baseline arm, and B1 is
model_dependent. So every comparison in the registered family inherits the flag, and under a mock provider the entire baseline table is uninterpretable for mechanism — which is why the honest contrast above is against B0, not B1. Fixing this means a different baseline, which means a different protocol. - The confidence-to-probability map grades the wrong thing.
CONFIDENCE_AS_PROBABILITYtranslates a rubric that measures evidence for an effect into a probability read as the answer is correct. B4 was right on 19 of 20 items while stating 0.40–0.75, because a correctno_effectanswer has weak evidence for an effect by construction. The institutional arms therefore score ECE ≈ 0.42 — systematic underconfidence, and an artefact of the mapping rather than a property of the institution. The Brier and ECE columns above measure the translation as much as the calibration.
Mechanisms that contributed nothing measurable. B5 − B4 = exactly 0.000: the Skeptic's detectors raised no finding that changed any verdict. B6 − B7 = −0.05, CI [−0.150, +0.000]: memory did not help and if anything cost an item. Neither is evidence of absence at this power — twenty items cannot resolve below one item — but neither may be reported as a benefit.
Where the accuracy actually comes from. B3, with role decomposition alone and no preregistration and no Custodian, already reaches 0.90. Everything the institution adds above that moves between zero and one item. On this bank, at this power, the decomposition is doing the work and the institutional machinery is not yet earning its cost — B4 is cheaper per correct claim than B3 ($0.00683 vs $0.00722), but only because it got one more item right.
The honest summary. The ladder demonstrates that the harness works, that the arms differ in the mechanism named and nothing else, and that the whole thing beats a do-nothing floor. It does not demonstrate that structure beats agents, because the bank is too small and the provider is a mock. Sharpening it needs a harder bank with items near the verdict boundary, more items, and a live model — which is what M10's results make the case for, rather than something the milestone can assert.
Static HTML report generator, then the FastAPI + HTMX dashboard: overview, hypothesis explorer, run monitor, genealogy graph, agent timeline, claim view.
Acceptance — a person answers "why does the system believe C-014?" in three clicks. Met in one. The index lists every claim; one click opens its dossier, which carries what raised the confidence, what capped it, the ledger facts underneath, the question it answers, the design locked before it ran, the seeds, the numbers the verdict came from, every objection with its discriminating test, and how the forecasts that were locked beforehand actually scored.
Static files, not a server — the shape docs/01-critique.md §24 argued for. A dashboard is a read model over an event log; serving it needs a process running at the moment someone asks, while a directory of HTML can be opened from a clone, attached to an issue, committed beside the results it describes, and diffed against the last one. For a project whose whole argument is that its record survives inspection, the artifact that travels beats the one that must be hosted. nullius report build exits non-zero on a broken chain, a ledger that does not reconcile, or a claim carrying a confidence the ledger no longer supports.
The report re-derives rather than displays. Confidence is not read out of the claims row and printed; it is recomputed from the same ledger facts compute_confidence consumes and compared against the stored value. Disagreements are listed first on the front page. That is only possible because M5 built every input to be a checkable fact rather than an opinion — and the first thing it did when pointed at a real ledger was find three places where that had quietly stopped being true.
Three bugs, found by re-deriving instead of displaying. All pinned by tests.
- The computed confidence never reached the ledger.
create_claimwritesspeculative; the kernel computed the real level, returned it inKernelOutcome, and never wrote it back. Everyclaimsrow in every ledger this project has ever produced saidspeculative— including claims that had been independently replicated and were reported aswell_supported. It stayed invisible because the benchmark read the in-memory value, which is exactly why something had to read from the store instead. The kernel now promotes throughpromote_claim, so the ledger's own rules — evidence exists, no open critical objection, an independent reproduction before the top level — get their say rather than a column being assigned. - The Custodian named artifacts it never stored. Every holdout
RunResultcarried a content address that resolved to no object; thedevresults written by the harness all resolved. So the evaluation numbers — the ones the verdict is actually computed from — had no artifact behind them. An address for an artifact that was never written is worse than no address, because it reads as provenance. The Custodian now writes the measurement payload and verifies the store addresses it identically. provenance_completewas asserted, not checked. The kernel passed the literalTrue. The confidence rubric's entire design is that no input can be declared, and one of them was being declared by the system on its own behalf every time — which meant this cap had never once fired in the project's life. Now computed from the store.
M10's results are unaffected. The benchmark read KernelOutcome.confidence, the correctly computed in-memory value, so benchmark/results.lock.json stands as measured. What changed is that the ledger now agrees with it.
Two new CI jobs. protocol re-scores the committed results from their own per-item rows, so a results file whose headline numbers cannot be recomputed from the outcomes it ships with cannot be committed. report carries three bank items through the full institution, renders them, and fails if the generator's own integrity check trips — the report has to survive its own check on output nobody curated, and the site is uploaded as an artifact.
Code generation (restricted op registry → constrained → free-form, measured against the compiler baseline, Docker required here), vendored literature corpus with a provenance verifier, versioned self-improving policies, template-rendered papers, and finally multiple labs.
Blocked, and stated rather than quietly skipped. Code generation needs a live model and Docker, and this machine has neither (docs/adr/0002, and the M0 environment table). It stays unbuilt until both exist. Nothing else in this bucket was started, because building more mechanisms on an instrument that cannot measure mechanisms is the mistake M10 was warning about.
M10's ladder separated no two institutional arms. Before adding anything, fix the instrument — which is entirely offline work.
Acceptance — a second registered bank on which the primary metric can resolve a difference smaller than one item, with ground truth that is not in doubt; and a second protocol repairing the three flaws running the first one exposed. Neither replaces the first.
The diagnosis in M10 was wrong, and measuring it properly says so. M10 concluded "the bank is too easy". It is not: thirteen of v1's twenty items already sat within two experiment standard errors of a verdict boundary, and the single item B4 got wrong (B15) was the third hardest in the bank. The actual limits were different, and both are arithmetic rather than judgement — twenty items make the primary metric move in steps of 0.05, so no difference smaller than one item can be seen at all, and only six items sat inside one standard error, which is the band where two arms can plausibly disagree. That correction is recorded here because the wrong version was committed in M10's own write-up.
Bank v2: sixty items, thirty of them inside one experiment standard error.
| v1 | v2 | |
|---|---|---|
| items | 20 | 60 |
| metric resolution | 0.050 | 0.017 |
| within 1 measured experiment SE | 3 | 20 |
| within 2 measured experiment SEs | 6 | 37 |
| true nulls | 45% | 45% |
| minimum oracle margin | ≥3 SE | 3.4 SE |
Corrected after the fact. The bank was designed against an assumed experiment standard error of 0.005; the ledger says the real one is 0.00348 (median over B6's 360 claims). Every item is therefore harder in relative terms than intended, and the counts above are the measured ones rather than the design-time estimates.
The headroom that makes a hard bank a fair one is the gap between the two measurements. The oracle sees 40 seeds of 20,000 samples and resolves an effect to about 0.0008; an experiment gets 5 seeds of 2,000 and resolves it to about 0.005. Every v2 item is at least three oracle standard errors from its boundary — its ground truth is not in doubt — while half of them sit inside one experiment standard error of it. Unambiguous to the oracle, a coin flip for the institution.
Every parameter was found by measuring a 311-point sweep of the generator and selecting on the result, never by picking a number that looked right. The causal branch turns out to be non-monotone below shift_strength≈0.5, so selection is restricted to the monotone branch above it. Truth derivation for all sixty items takes 69 seconds.
Protocol v2 repairs the three flaws, and repairs none of them in place. v1 stays on disk, still verifying, still wrong in the three ways it was wrong — editing a hashed preregistration to fix its own findings is the exact substitution the file exists to prevent.
- Baseline arm B1 → B0. B1 is
model_dependent, so under a mock every comparison in v1's registered family inherited the flag and the whole table was uninterpretable for mechanism. B0 answers without looking and is not a model. - Adjudication on an interval, not two point estimates. v1's rule returned "upheld" for a one-item difference. v2 requires the B4−B3 interval to exclude zero, and fails the prediction if it does not, whatever the point estimate says.
- Calibration scored only where the rubric's quantity is the scored quantity. The confidence rubric measures evidence for an effect, so a correct
no_effectanswer necessarily carries weak evidence and scored as gross underconfidence — ECE ≈ 0.42 was an artefact of the mapping, not a property of the institution. v2 registersasserted_effects.
A fourth flaw, found while fixing the other three. Adding those two keys to the builder changed what build_protocol(version="1") produced, while every existing check stayed green: bank unchanged, arms unchanged, stored hash still matching its own content. A registered protocol the code can no longer reproduce has been edited in effect. ProtocolVerification now carries rebuilds_identically, v1's payload gained no keys, and v1 rebuilds to 1d4c76d2… exactly as M10 registered it.
nullius benchmark run --bank 2 runs the ladder on v2. --bank 1 still reproduces M10.
benchmark/results.v2.lock.json, protocol 254be687…, 60 items, mock provider.
| arm | acc | null | brier | ece | fdr | $/correct | |
|---|---|---|---|---|---|---|---|
| B0 | oracle-null | 0.45 | 1.00 | — | — | 0.00 | 0.00000 |
| B1 * | single-shot | 0.18 | 0.00 | 0.197 | 0.217 | 0.45 | 0.00293 |
| B2 * | + loop | 0.18 | 0.00 | 0.197 | 0.217 | 0.45 | 0.00830 |
| B3 | multi-role | 0.60 | 0.33 | 0.203 | 0.450 | 0.00 | 0.01081 |
| B4 | + prereg + custodian | 0.67 | 0.37 | 0.164 | 0.394 | 0.00 | 0.00973 |
| B5 | + Skeptic | 0.62 | 0.41 | 0.151 | 0.309 | 0.00 | 0.01052 |
| B6 | full institution | 0.72 | 0.48 | 0.110 | 0.281 | 0.00 | 0.00919 |
| B7 | full − memory | 0.70 | 0.48 | 0.101 | 0.265 | 0.00 | 0.00940 |
The prediction is refuted, by the rule registered before the run. B4 − B3 = +0.067, 95% CI [−0.033, +0.167] — the interval spans zero, so the prediction fails regardless of the point estimate. v1's weaker rule would have called the same data "upheld". The project was wrong in public about its own registered prediction, which is the outcome preregistration exists to make possible.
Only the full institution beats the do-nothing baseline. After Benjamini–Hochberg across the family of seven, four comparisons survive: B1−B0 and B2−B0 (both negative, and model-dependent), and B6−B0 = +0.267 [+0.050, +0.450] and B7−B0 = +0.250 [+0.050, +0.467]. B4−B0 = +0.217 with a lower bound of exactly 0.000 and does not survive. So the cheap-mechanism arm is not what separates — the expensive one is. That is the opposite of what was registered.
Calibration improves monotonically down the ladder, and this is the clean signal. Brier 0.203 → 0.164 → 0.151 → 0.110 → 0.101 and ECE 0.450 → 0.394 → 0.309 → 0.281 → 0.265 across B3→B7. Every added mechanism improves it, without exception. v1 could not see this at all, because v1's calibration metric was measuring the confidence mapping rather than the institution — the flaw protocol v2 was registered to fix.
Never once a false discovery. FDR is 0.00 for every institutional arm. Of B3's 24 wrong answers all 24 are inconclusive, and of B6's 17, all 17.
Corrected in M13. This section originally said "every error the institution makes is an abstention". That was wrong, and the error was mine rather than the system's. Splitting those
inconclusiveanswers by the branch ofderive_verdictthat produced them shows most are not abstentions at all: 16 of B3's 24 and 11 of B6's 17 are substantive findings — the interval ruled out the claimed effect but not a smaller one, so the institution asserted a real sub-MDE effect where the truth was a null. Only 8 and 6 respectively were genuine "the interval is too wide to say anything". A wrong finding and a declined question are different failures, and I reported the first as the second.
A flaw in the primary metric, now visible because the bank is hard. verdict_accuracy scores a calibrated "I cannot tell" exactly as harshly as a confident error — the distinction an institution exists to make, and the headline metric was blind to it. Null accuracy falling from 0.89 (v1) to 0.33–0.48 (v2) is largely this effect rather than a decline in judgement.
And worse than that, also found in M13. Because
inconclusiveis a real truth value in this bank, the conflation did not only penalise abstention — it rewarded it. An arm whose interval was too wide to say anything was scored correct whenever the truth happened to beinconclusive. Every arm above is inflated by it, unevenly: B3 by 7 items, B4 by 9, B5 by 8, B6 by 4, B7 by 9, out of sixty. Deflated, the accuracies are B3 0.483, B4 0.517, B5 0.483, B6 0.650, B7 0.550 — and the B6/B7 gap widens from +0.017 to +0.100. The ordering this section rests on is not safe. M13 splits the verdict and re-runs the ladder; the numbers above stand as what protocol v2 measured, not as what is true.
And the abstentions are correct, which is a power finding about the design. The measured experiment standard error is 0.00348 at the policy's min_seeds: 5. The hardest null sits 0.0030 from the null-band edge. Separating them at three standard errors needs about 61 seeds. The institution is being asked questions its own declared design cannot answer, and it says so instead of guessing — so the right target for M13 is the seed policy, not the agents.
Memory still contributes nothing measurable. B6 − B7 = +0.017, CI [−0.050, +0.083]. Consistent with v1, on a bank three times the size.
The metric flaw M12b exposed, fixed at the representation rather than with a new scoring rule.
Acceptance — an abstention can never be scored as a correct answer; coverage and accuracy-when-answering are reported beside the headline; the ladder re-runs under a protocol that registers all three.
One enum value was doing two jobs. derive_verdict returns inconclusive both for "the interval rules out the claimed effect but not a smaller one — something is there, less than was claimed" and for "the interval is too wide to separate anything; this is a statement about the design, not the world". Its own reason strings say exactly that. The benchmark scored the enum, so the distinction never left the function.
The cost was not what I first thought. M12b reported that the institution's errors were all abstentions. They were not: most were substantive findings that were wrong. And because inconclusive is a real truth value here, the conflation ran in the flattering direction — an arm that could say nothing was credited with a correct answer whenever the truth happened to be inconclusive. That inflated every v2 arm, unevenly, and by enough to move the ordering.
The fix is representational. Verdict.UNDERPOWERED is a separate member, and it is never a truth: the oracle measures at forty seeds of twenty thousand samples and is never short of power, so classify cannot produce it and an abstention cannot be scored correct by accident. This was preferred over a scoring rule with an abstention weight, because that weight would have been a free parameter chosen after seeing which value flattered the institution.
VerdictReport.underpowered already existed and already computed this — by searching its own reason string for "too wide". It is an identity check now.
Protocol v3 registers the vocabulary and adds coverage and assertion_accuracy beside the headline. Neither is primary: an arm reaches assertion accuracy 1.0 by answering only what it is sure of, and coverage is what stops that reading as success. verdict_accuracy still counts an abstention as incorrect — v1's first exclusion rule refuses to pay an arm for declining the questions it found hardest, and separating abstention from error is not forgiving it.
Per-version metric tuples were needed to do this without vandalism: adding two entries to the shared METRICS constant would have changed the hash of two protocols that are supposed to be immutable. v1 and v2 both still verify and still rebuild identically.
benchmark/results.v3.lock.json, protocol 9eb8e1e1…, 60 items, mock provider.
| arm | acc | coverage | when answered | null | brier | ece | fdr | |
|---|---|---|---|---|---|---|---|---|
| B0 | oracle-null | 0.45 | 1.00 | 0.45 | 1.00 | — | — | 0.00 |
| B1 * | single-shot | 0.18 | 1.00 | 0.18 | 0.00 | 0.197 | 0.217 | 0.45 |
| B2 * | + loop | 0.18 | 1.00 | 0.18 | 0.00 | 0.197 | 0.217 | 0.45 |
| B3 | multi-role | 0.48 | 0.75 | 0.64 | 0.33 | 0.203 | 0.450 | 0.00 |
| B4 | + prereg + custodian | 0.52 | 0.77 | 0.67 | 0.41 | 0.142 | 0.297 | 0.00 |
| B5 | + Skeptic | 0.55 | 0.75 | 0.73 | 0.44 | 0.132 | 0.350 | 0.00 |
| B6 | full institution | 0.57 | 0.78 | 0.72 | 0.41 | 0.117 | 0.294 | 0.00 |
| B7 | full − memory | 0.57 | 0.73 | 0.77 | 0.48 | 0.118 | 0.292 | 0.00 |
Every institutional arm lost accuracy, and the non-abstaining arms lost none. B3 −0.117, B4 −0.150, B5 −0.067, B6 −0.150, B7 −0.133; B0, B1 and B2 unchanged to three decimals. That is the control: those three never abstain, so the vocabulary change cannot touch them. The drop is the correction, not run-to-run variation.
M12b's headline is retracted. It said only the full institution beats the do-nothing baseline, on B6−B0 = +0.267 [+0.050, +0.450]. Scored correctly, B6 − B0 = +0.117, CI [−0.083, +0.317] — it spans zero. After Benjamini–Hochberg only two of seven contrasts survive, and both are the negative model-dependent ones. At this power, nothing in the institution beats answering no_effect about everything.
The one contrast that separates decisively is coverage, in the direction that costs the institution. B6 − B0 = −0.217, CI [−0.317, −0.117]. The institution answers about a fifth less of the bank than the constant does. It is right more often when it does answer (B6 0.72 against B0 0.45), but that gap does not separate either: B6 − B0 when answered = +0.213, CI [−0.043, +0.468] over the 47 items both were willing to call.
So the institution trades coverage for accuracy, and at sixty items neither side of that trade is resolvable. Only the trade itself is visible.
Calibration remains the one robust signal, across all three protocols. Brier 0.203 → 0.142 → 0.132 → 0.117 → 0.118 down B3→B7. It survived the bank getting harder and the metric getting fixed, which is more than the accuracy ordering did.
The registered prediction is refuted, and the way it was adjudicated is a flaw I introduced. The prediction had two clauses. The first — separating abstention from finding lowers every arm's verdict accuracy — is true. The second — the institutional arms separate on coverage, B6 answering more of the bank than B3, interval excluding zero — is false: B6 − B3 coverage = +0.033, CI [−0.050, +0.117].
But the run reported "refuted" without testing either clause. Protocol v3 inherited v2's adjudication: interval_excludes_zero, which computes B4 − B3 on accuracy, while v3's prediction text is about coverage. The verdict is right and was reached by measuring something the prediction does not mention. A registered prediction and a registered adjudication rule that measure different quantities is a fifth protocol flaw, and this one is purely mine — I wrote new prediction text and left the rule key at its inherited value. A v4 must derive the adjudicated quantity from the prediction rather than storing them independently.
The prediction did at least say in advance how to read its own failure: "If coverage does not separate, the institution's advantage is in what it says and not in how much it is able to say." On these numbers that is the reading — the advantage is in calibration and in declining to answer, not in answering more.
benchmark/results.v4.lock.json, protocol b46bdef3…, 9 arms, 60 items, mock provider.
| arm | acc | coverage | when answered | null | brier | ece | $/correct | |
|---|---|---|---|---|---|---|---|---|
| B0 | oracle-null | 0.45 | 1.00 | 0.45 | 1.00 | — | — | 0.00000 |
| B3 | multi-role | 0.48 | 0.75 | 0.64 | 0.33 | 0.203 | 0.450 | 0.01341 |
| B4 | + prereg + custodian | 0.62 | 0.80 | 0.77 | 0.52 | 0.155 | 0.314 | 0.01051 |
| B5 | + Skeptic | 0.62 | 0.83 | 0.74 | 0.48 | 0.150 | 0.311 | 0.01051 |
| B6 | full institution | 0.55 | 0.75 | 0.73 | 0.44 | 0.101 | 0.265 | 0.01196 |
| B7 | full − memory | 0.55 | 0.75 | 0.73 | 0.48 | 0.118 | 0.226 | 0.01196 |
| B8 | + adaptive seeding | 0.73 | 0.93 | 0.79 | 0.56 | 0.088 | 0.248 | 0.00944 |
The prediction is upheld. Coverage, B8 − B6 = +0.183, 95% CI [+0.083, +0.300], p = 0.001 — the interval excludes zero, which is what v4 registered as the test. B8 abstains on 4 of 60 items where B6 abstains on 15. This is the first registered prediction the project has got right, and it was adjudicated on the quantity the prediction actually named, which v3 could not do.
B8 is the only arm that beats the do-nothing baseline. B8 − B0 = +0.283, CI [+0.083, +0.483], p = 0.009, and it survives Benjamini–Hochberg. Three of eight contrasts survive: B1−B0 and B2−B0 (negative, model-dependent) and this one.
And it is the cheapest institutional arm per correct claim — $0.00944 against B6's $0.01196 — despite running several times the seed-runs. Total spend is only 5% higher ($0.415 against $0.395), because token cost is per role-call and does not scale with seeds; compute is nearly free next to as-if-priced tokens. Buying resolution is cheap in exactly the currency this benchmark measures.
A stability finding that qualifies everything above. Arms B0–B7 ran twice — once under v3, once again under v4 — which is an unplanned replication of the whole ladder. B0, B1, B2 and B3 came back identical to three decimals; B4 moved +0.100, B5 +0.067, B6 and B7 −0.017.
The split is not arbitrary. B3 has no Custodian, so it reads the development split, which is deterministic given seeds fixed by item id. Every arm that varies is a custodied arm, and the Custodian's evaluation seed is derived from the registration id — a fresh UUID per run. That is deliberate (docs/03: a custody seed derived from the design would let anyone re-register a spec and shop a fixed evaluation set), and the price is that custodied arms move by up to 0.100 between runs, six times the metric's 0.017 resolution.
So: B4 − B3 is not a stable finding. It was +0.033 spanning zero in v3 and +0.133 excluding zero in v4, on the same arms and the same bank. The swing is the custody draw. B8 − B6 at +0.183 is larger than any single-arm swing observed, and B8 − B0 at +0.283 is larger still, which is why those are reported as findings and B4 − B3 is not.
The next milestone is replication of the ladder itself. One draw per arm is not enough at this resolution, and the project has now measured how much it is not enough by. Every arm should run several times and the report should carry the distribution rather than a single number.
nullius paper build renders paper/index.html from the committed protocols and results. A roadmap item (docs/06, template-rendered papers) and the natural place for the record to end up.
Acceptance — the document cannot report a flattering subset.
It does not select because it does not choose. Every registered protocol appears in registration order with its prediction and its outcome: two upheld, two refuted, one registered and not yet run — and the unrun one is labelled as such rather than omitted, because a plan with no result is part of the record too. Two results that later protocols retracted are still in the document, under the protocols that produced them.
Nothing numeric is typed. Every figure is read from a results file whose stored summary re-scores from its own per-item rows; every prediction is read from a protocol whose hash is in the git history; bank difficulty is computed from the locked truths. assemble(strict=True) refuses to build when a protocol fails to verify or a results file fails to re-score — a paper whose inputs no longer check out is worse than no paper, because it looks like evidence.
Two prose sections, declared as data. The six flaws and five limitations are the only hand-written content, held in render.py as constants so they can be counted and checked in one place. Each flaw names the milestone whose commit records it, and a test enforces that.
The flaw list is the section a written-up-afterwards paper would not have, because in that genre the flaws are fixed before anything is published. Here they are the record: five of them were found by executing a preregistered plan rather than by reviewing one.
A ninth CI job builds it on every push and uploads it as an artifact.
Found while measuring something else, which is how most of this project's findings have arrived.
M14's escalation reads a standard deviation off the paired differences of the five mandatory seeds and sizes the extra seeds from it. Five points make a much worse estimate than it looks. Simulated at the measured paired SD of 0.00348:
| SD estimate | seeds it asks for | |
|---|---|---|
| 5th percentile | 0.00147 | 4 |
| median | 0.00320 | 7 |
| truth | 0.00348 | 8 |
| 95th percentile | 0.00533 | 15 |
It lands under half the true value 8.9% of the time. When it does, the escalation buys four seeds where eight are needed, the item stays underpowered, and it abstains — a failure on exactly the questions the mechanism was added to answer.
The fix is an upper confidence limit, and the asymmetry is the point. B9 sizes from the 80% chi-square upper bound rather than the point estimate, so uncertainty about the noise buys more data instead of less. Over-buying costs compute, which this project has measured at 5% of total spend for several times the seed-runs; under-buying costs an answer. It is not free: at five observations the bound is 1.56× the estimate, and seeds scale with the square, so the median escalation roughly doubles. Protocol v6 registers that cost as part of the prediction — if cost per correct claim rises without coverage improving, the bound is only expensive.
A near-miss worth recording. The measurement that started this was of sample size, not seeds, and a five-seed estimate said quadrupling samples cut the SD fivefold. At thirty seeds the same measurement said 0.73×, and the ordering was not even monotone. The first number was noise. I nearly built a milestone on it, and the thing that stopped it was re-measuring with more seeds — which is the same lesson the finding itself is about.
A third instance of the same bug shape. The paper's results-path table was a dict keyed by protocol version, and it raised a KeyError the moment v6 was registered. That is the third time something keyed by protocol version was maintained beside the registry instead of computed from it — after the CI job that listed protocols by hand, and the ladder that ran eight arms under a nine-arm plan. It is derived now.
All seven protocols verify and rebuild identically. v6 has since been run, and refuted — against an arm that never sized anything conservatively. The upper bound described here was built correctly and measured correctly; what was missing was the wire from the arm to it. M23 has the count of what that cost.
The README said "That may turn out to be false. The benchmark is designed to be able to say so." It had said so, four times, and the front door did not mention it.
FINDINGS.md is generated, by the same assembled record the HTML paper renders — a second rendering, not a second account, so the two cannot disagree. CI regenerates it on every push and fails on git diff --exit-code. The check was verified in both directions: stable across regeneration, and it catches a one-line hand edit.
That closes the one place this project was asking to be taken on trust. A repository whose thesis is take nobody's word for it cannot ask a reader to take its README's word for it.
The design documents are annotated, not rewritten. docs/04-evaluation.md staked out the headline prediction in advance — B4 will capture most of the gain over B3 — and a reader had no way to learn it was refuted. It now carries a note saying so, with the intervals, and pointing at the generated record. The document itself is unchanged, on the same principle that keeps a superseded protocol on disk: the design is a historical record, and editing it to match the result would destroy the thing that makes the result meaningful.
docs/00-README.md carries the same warning at the top of the set.
benchmark/results.v5.lock.json, protocol 6bfaa136…, 9 arms, 60 items, three passes per custodied arm.
| arm | n | acc | coverage | answered | null | brier | ece | $/correct |
|---|---|---|---|---|---|---|---|---|
| B0 | 1 | 0.45 | 1.00 | 0.45 | 1.00 | — | — | 0.00000 |
| B3 | 1 | 0.48 | 0.75 | 0.64 | 0.33 | 0.203 | 0.450 | 0.01343 |
| B4 | 3 | 0.52 | 0.74 | 0.70 | 0.41 | 0.136 | 0.333 | 0.01242 |
| B5 | 3 | 0.55 | 0.78 | 0.71 | 0.42 | 0.150 | 0.329 | 0.01179 |
| B6 | 3 | 0.53 | 0.74 | 0.71 | 0.42 | 0.111 | 0.285 | 0.01249 |
| B7 | 3 | 0.57 | 0.76 | 0.76 | 0.51 | 0.121 | 0.299 | 0.01151 |
| B8 | 3 | 0.72 | 0.87 | 0.83 | 0.64 | 0.090 | 0.250 | 0.00971 |
Both clauses of the prediction hold, and the second is the point. Coverage B8 − B6 = +0.122, CI [+0.044, +0.211], p = 0.002 — upheld. And B4 − B3 = +0.039, CI [−0.056, +0.133] — still spanning zero, exactly as registered.
That settles the contrast that flipped. v3's single draw said +0.033 spanning zero; v4's said +0.133 excluding it. Averaged over three custody draws it reads +0.039 and does not separate. The v4 reading was the noise, and the protocol that predicted so in advance was right to be cautious. This is the clearest thing replication has bought: a contrast that a single draw would have published.
B8 remains the only arm that beats the do-nothing baseline — +0.267, CI [+0.094, +0.439], p = 0.003, surviving Benjamini–Hochberg — now on three passes rather than one, and still the cheapest institutional arm per correct claim.
Memory has now failed to show a contribution across four protocols. B6 − B7 = −0.044, CI [−0.106, +0.011]; the point estimate is on the wrong side of zero.
The README claims results trace to hashed artifacts and the repo rebuilds from a clean clone. Until M15 that was false for every custodied arm — identifiers were random UUIDs, so the Custodian's evaluation seed differed on every run and no custodied result could be reproduced at all. It held only for arms that never query the Custodian, which is exactly why running the ladder twice left B0–B3 identical and moved everything above.
Measured now: running a custodied arm twice gives bit-identical verdicts and realised effects to nine decimals. Every scientific field matches. Only usd differs, by about 0.2% — compute is billed from wall-clock seconds actually consumed, which no seeding makes deterministic. Reporting it as reproducible would be the comfortable lie; dropping compute from the cost would make the economy measure half of what a run spends. A tenth CI job runs the comparison on a clean machine.
Two display and reconstruction bugs, both found by reading the output rather than trusting it. The results table had lost the n header while still emitting the value, so every column after arm was shifted by one and the whole table misread — my own first summary of v5 was wrong because of it. And read_results rebuilt outcomes field by field without replicate, so a three-pass arm reported itself as one-pass, which is the single number telling a reader how much replication is behind the figures. That is the fourth time a reconstruction path drifted from its schema, so the fix is a round-trip test comparing whole dicts rather than another named field.
Memory has failed to show a contribution across five protocols. That is not a finding about memory.
Memory can only act by changing what a model writes. It adds recalled claims to the Theorist's view; nothing else. The mock's response is byte-identical with and without them — verified directly. So B6 − B7, whose arms differ in that one switch, measured the difference between two custody draws and was reported as memory's contribution in v1 through v5.
B1 and B2 were labelled model_dependent from the start for exactly this reason. Memory was not, and four registered protocols carried a null result for a switch that was delivered and discarded.
The fix is general, not a special case for memory. MODEL_MEDIATED names the switches that act only through a model — memory and iterations — and Arm.differs_only_by_model detects any contrast separated by nothing else. Contrasts so identified are labelled not interpretable rather than printed as intervals. The rule independently re-derives B2 − B1, which was already flagged, and leaves B4 − B3, B8 − B6, B6 − B4 and B9 − B8 interpretable, so it is narrow enough not to excuse every null.
Re-scored across all five committed results it flags exactly one contrast per protocol, the same one. No adjudicated prediction was ever made on B6 − B7, so no headline moves. This is a reporting correction, not a new analysis plan, and it needs no protocol v7.
A false alarm worth recording. The first diagnosis was that recall() returned nothing because each bank item runs in its own programme. It returned nothing when I called it — with the default scope="program". The kernel calls it with scope="lab", which returns ten claims. I had started editing recall to "fix" something that worked. Checking what the caller actually passes is what stopped it.
Three malformed lists that had been shipping since M18. Jinja's trim_blocks eats the newline after a block tag, so every Markdown bullet whose line ended with an inline {% endif %} silently joined the next — the baseline comparisons, the new contrasts, and the limitations all rendered as single run-on lines. The CI drift check could not see it: that check verifies the committed file matches the generator, and both were wrong in the same way. A consistency check is not a correctness check. Two structural tests now assert the generated Markdown is well formed — one caught two of the three lists on its first run, and the table test encodes the shifted-column bug from M19.
Everything below was reachable only by a mock until now. No API spend yet; the machinery is what changed.
A · Wired. AnthropicProvider and ProviderRefusal are exported. nullius.llm.factory is the one place that turns a name into a working provider, and --provider / --cache / --model / --max-usd reach the runner and the kernel.
Two things were quietly false before. runner.py built ModelRef(provider="mock", model="mock-1") as a literal, so a live run would have recorded every B1 and B2 call as answered by a mock — and since the pricing table is keyed on model name, priced them at zero. And cli.py wrote provider="mock" into the results file as a literal, so a live run would have produced a results file claiming to be a mock run. For a project whose entire argument is that its record survives inspection, that is fatal rather than untidy. Both now carry what was actually used.
detect_live_provider() used only to print a row in doctor. It gates now: --provider anthropic with no key exits 1 before a database, a workroot or a results file exists. Verified — nothing is created.
B · Survivable. worker.py called provider.complete() bare. The only exceptions handled anywhere in that loop were ValidationError and ValidationFailure — schema problems. Over a multi-hour ladder, one 429, 529 or dropped connection is a certainty, and it would have raised straight out and ended the run after everything up to that point was paid for.
RetryingProvider wraps the network with exponential backoff and full jitter. A decorator, not a change to the worker, because a malformed response is evidence about the prompt and a rate limit is evidence about traffic — mixing them would spend the repair budget on weather. Retried statuses are 408/409/429/5xx/529; 400 and 401 are not, since they fail identically however often they are sent. An unrecognised error is raised rather than retried.
ProviderRefusal existed and nothing consumed it. A refusal is now a recorded failed task with its reason, in the ledger — retrying it would pay for the same answer, raising it would end a ladder over one role's prompt.
SpendGuard is the --max-usd kill switch. The budget machinery caps a programme, and the benchmark gives every item its own — so a $100 programme cap permitted sixty of them per arm and nothing was watching the total, which is the number that matters overnight. Checked at item boundaries; finer would interrupt a half-executed experiment, coarser is a whole arm too late.
Checkpointing was built for a crash while writing results. A mid-arm API failure is a different shape, and is now tested as one: a finished arm is on disk, the arm in flight is not, and resuming does not re-run what was already paid for.
C · Cheap to repeat. The live stack is Caching(Retrying(Anthropic)), and the order is the point — cache outside retry, so a call that succeeded on its fourth attempt is written once and every later run of that request is free. Reversed, nothing would be recorded. Tested.
The cost estimate covered three of five roles. contracts_for holds Theorist, Designer and Analyst; Skeptic and Reviewer live in adversarial_contracts and are a disjoint set, so the pre-flight number omitted them — and the Skeptic is by far the most expensive, reading the whole evidence bundle on Opus at $0.036–$0.131 a call. A cycle now prices at $0.089–$0.303 rather than a fraction of it. A cost estimate that silently prices part of the work is worse than none, because it is the number someone buys credit from.
A live smoke test runs one bank item end to end with a key and skips rather than fails without one.
A wiring bug worth recording. The first v4 ladder ran eight arms under a nine-arm protocol: a ruff format pass had collapsed the run_ladder(...) call onto one line before an edit meant to add arms= to it, so the replacement matched nothing and the runner silently used its eight-arm default. It produced a complete-looking results file — seven of seven baseline comparisons, no halted items — and nothing objected except the adjudication, which happened to name the missing arm by id. score_ladder now refuses any run whose arms do not match the protocol's, in either direction. The eight completed arms were reused from their checkpoints, so the correction cost one arm's compute rather than nine.
A gamified floor plan of the institution: fourteen rooms, one per department, laid out from
db/enums.py and filled in from the committed record. nullius station build renders it to a
single self-contained HTML file, and refuses on the same terms the paper does.
The map is not a picture of the architecture; it is the architecture, laid out. A room
declares which Roles work in it and which HypothesisStates it owns. unrepresented_roles()
and unrepresented_states() are computed and a test fails on either being non-empty, so adding
a role or a state to the enum breaks the build until a room claims it. The corridor's order is
the order the states are declared in, and the exits are TERMINAL_STATES — both read off the
enum rather than listed beside it. This is the fourth thing in this project keyed by a
project-level enum, after the CI job that listed protocols by hand, the ladder that ran eight
arms under a nine-arm plan, and the paper's results-path table that raised on v6. It is the
first one derived.
Two modes, and the page says which. Aggregate reads only committed artifacts — protocols, results, locked truths — so it builds from a clean clone and in CI. Ledger adds one arm's SQLite ledger for the per-agent detail the lock files do not carry: registrations with their timestamps, objections with their discriminating tests, holdout queries, the query audit, and tokens per role. A room whose only source is the ledger renders empty without one and says so.
Every figure names the artifact it came from. Figure has no constructor that omits
source, so a number cannot reach the page without pointing at the file or table it was read
out of. The hand-written prose — four principles, four limitations, and each room's charter and
invariant — is declared as data, and a test asserts that none of it contains a digit, milestone
tags and arm ids excepted. That is the paper's discipline applied to a medium that is more
persuasive and less obviously checkable.
No animation without a backing row. A token's route is computed from its arm's switches and its colour from the recorded verdict against planted truth; the pacing is display, and the page says so on its own face. Nothing is drawn conferring, because the architecture denies that agents converse and a drawing that showed them doing it would make the interface lie about the system. The page states how many of the recorded passes are in motion rather than letting a subset read as the whole record.
The Registry is the one room built to depth, because its invariant is fractal: the protocol is hashed and committed before the ladder that tests it exists, and one level down each design is hashed and locked before its own run. Both are shown as checks rather than claims — the git history for the first, and a count of runs that began after the registration authorising them for the second. On a v5 institutional ledger that is 600 of 600. The other ten dashboards are deliberately not built; design mistakes get replicated ten times if you build them all first.
The Vault is the room you cannot see into from inside. It reports 1,800 holdout rows
computed by the Custodian and zero computed by anyone else — the CHECK constraint holding
on the record in front of it, which is a different statement from the constraint existing. B3,
the arm with no Custodian, leaves it untouched, which is the switch being connected rather than
merely recorded.
1. A registered switch that reaches no code at all. Arm.reviewer is declared on every arm,
hashed into every registered protocol from v1, and set on B6, B7, B8 and B9. mechanisms_for
maps seven fields into the kernel and reviewer is not one of them; nothing else in src/
reads it. Flipping it leaves the kernel's switches identical, so an arm that sets it does not
differ from one that does not. The reviews table is empty in every ledger this project has
produced, and adversarial_contracts — which holds the Reviewer's contract, input view and
validator — is consumed only by the cost estimator.
This is M20's shape, one layer lower. M20 named the switches that can act only through a
model; there was no rule for a switch that acts through nothing. unread_switches() now finds
them by flipping one field at a time on a probe arm and asking whether the resulting
Mechanisms changes, and HANDLED_OUTSIDE_THE_KERNEL declares the two that legitimately act
elsewhere — iterations, read by the direct-agent path, and model_dependent, which is a label
and not a switch. Everything left over is reported as dead on the page. A test proves the probe
can tell a live switch from a dead one before it is believed about reviewer, and another
proves the excuse dict cannot grow into a place to hide one.
No result moves. B5 → B6 is documented as adding replication, review and memory; review contributes nothing, and memory is model-mediated and already labelled uninterpretable. So that step measures replication, plus custody noise. No adjudicated prediction was ever made on it. This is a reporting correction, not a new analysis plan — and wiring the Reviewer would change what every institutional arm does, which is a protocol v7 and not a bug fix.
2. Nine of fifteen states are written by nothing, and no hypothesis has ever reached a
terminal one. advance_hypothesis is called with DRAFT, SHELVED, REGISTERED, EXECUTED,
ANALYZED and ABANDONED_BUDGET anywhere in src/. SCREENED, BUILT, CHALLENGED,
REPLICATED, REVIEWED, INSTITUTIONAL, REFUTED, INCONCLUSIVE and REVISED are written by
no code path at all. Every hypothesis in every ledger this project has produced sits at
analyzed.
The work is done — there are bundle.built, objection.raised, replication.recorded and
claim.promoted events in the same ledgers — it is simply not recorded as state. So the
station draws every terminal exit at the same width and every one of them reads zero, which is
the honest picture and a sharper one than the drawing I set out to make.
docs/02-architecture.md §3 states as a hard invariant that the report generator fails loudly
if any registration lacks a terminal state. Nothing implements that, and if it did, every run
in the project's history would trip it. Recorded as
ADR-0008.
3. Two enums share two words, and reading one off the other would be the drawing's easiest
lie. Verdict and HypothesisState both contain refuted and inconclusive. The first is an
answer about the world, scored against planted truth; the second is where a hypothesis stopped.
The record room keeps them in separate blocks with the distinction stated, and the test that
guards it asserts the overlap still exists rather than asserting it away.
Acceptance
nullius station buildproduces a self-contained page from committed artifacts alone, with no ledger present — no CDN, no stylesheet, no remote image, everyhrefpointing inside the file.assemble(strict=True)refuses a protocol that does not verify and builds when they all do; both directions tested, and a page built withstrict=Falsesays so on its face.- Every
Figurecarries a source, and no hand-written string on the page contains a digit. - Every
Roleand everyHypothesisStateis represented, each state by exactly one room. model_dependentarms are labelled, and a contrast separated only by a model-mediated switch is shown as not interpretable rather than as an interval — using the paper's owncontrast_note, so the two documents cannot describe one interval in two different ways.- The provider is read from the results file and displayed; tested with a mock and with a live value.
- Reading a ledger cannot write to it, proven by trying rather than by the URI looking right.
- An eleventh CI job builds the station on every push and uploads it as an artifact.
The v6 ladder finished: twenty-two passes, ten arms, benchmark/results.v6.lock.json.
PREDICTION REFUTED coverage (B9-B8) = +0.0333, 95% CI [-0.0111, +0.0889]
That refutation is not about conservative sizing. B9 is B8 with one boolean,
conservative_escalation, which sizes the escalation from an 80% chi-square upper bound on the
noise instead of the point estimate. The field is declared on the arm, hashed into protocol v6,
carried by mechanisms_for into the kernel's Mechanisms, read at kernel.py, and handed to
_replicate(conservative=…). _replicate never used it, and neither call to _escalate
passed it at all. So the flag crossed every boundary and stopped one line short of the
calculation.
The signature is unmistakable once looked for:
| B8 | B9 | |
|---|---|---|
| seed counts, 180 outcomes | 5×60, 6×6, 7×3, 8×3, 9×3, 11×6, 14×6, 18×6, 19×6, 21×6, 23×3, 24×72 | identical |
| total seeds bought | 2,703 | 2,703 |
| outcomes where the two differ | — | 0 of 180 |
Not similar. Identical, item by item and replicate by replicate. After threading the flag through both call sites, the same two items move from 12 seeds to 21.
Three checks, and the one that was missing. Each catches what the one above it cannot.
unread_switches()(M22) flips a field and asks whetherMechanismschanges. It foundreviewer. It cannot find this one —conservative_escalationis a field onMechanisms, so flipping it does change the object.- A parameter accepted and never read.
ARGis now selected in ruff, and it names the bug in one line:kernel.py:922 ARG002 Unused method argument: conservative. This is the check that would have cost a second rather than a six-hour ladder. Five modules are exempted —@detector,@register_view,@transform_op, SQLAlchemy'sTypeDecoratorand its event listeners, where the signature is the interface — andkernel.pydeliberately is not.tests/test_switches.pyasserts the same rule over the kernel by AST, so no edit topyproject.tomlcan switch it off where it cost something. Enabling the rule also found a genuinely dead parameter inExperimentRunner._charge, which tookartifactsand billed fromresult.outputs; it is deleted rather than exempted. - Running both arms and comparing what they bought. Only an execution can prove a switch is connected — an argument can be read, passed on, and still reach nothing, which is exactly what happened here. Marked slow, two items, and it fails on the pre-fix kernel.
What v6 is evidence of. Not conservative sizing, which it never ran. B9 minus B8 is an unplanned negative control: two arms that were operationally identical, differing only in registration id and therefore in the Custodian's evaluation draw. Its interval is a measured noise floor for this ladder at three replicates — coverage +0.033 [−0.011, +0.089], and verdict accuracy −0.044 [−0.117, +0.028] computed afterwards and labelled as such. The interval covered zero on a contrast whose true difference is zero, which is the first calibration check this project's bootstrap has had. One draw cannot establish coverage, and it is not offered as if it could; it is one honest observation where there were none.
It also sets the scale for the neighbours. v6's other three contrasts — B4−B3 = +0.039, B6−B4 = +0.006, B6−B7 = −0.044 — are all the size of a difference now known to be zero, and all three already span zero. The bootstrap was saying so; the control is the first thing to corroborate it from outside.
v6 stands on the record. Its lock file is committed, its verdict is reported as refuted, and
the paper says in the same breath that the refutation adjudicates nothing. Deleting a registered
protocol's result because the run embarrassed the code is the exact move this project exists to
make impossible. Protocol v7 re-registers v6's prediction — same ladder, same adjudicated
contrast, hash 0dac6ca41fdb — against a switch that is connected. It has not been run.
The shape, for the third time. M20: a switch that acts only through a model, reported as mechanism. M22: a switch that reaches no code at all. M23: a switch that reaches the kernel and is dropped inside it. Each was found by asking a different question about the same claim — that an arm's declared mechanisms are the mechanisms it runs — and each time the previous milestone's check was structurally unable to see the new case.
And a fourth hand-maintained list. The CI job that re-scores every committed results file
named five paths in a tuple. There were six on disk, so results.v6.lock.json — the run this
whole milestone is about — would have gone through CI unchecked. It is a glob now, and it fails
loudly if the glob matches nothing rather than passing vacuously.
And M18's bug, one shape along. Every chapter heading in FINDINGS.md after the first has
been rendering glued to the previous paragraph — …rests on them.### Protocol v2 — since the
document existed. A {%- else %} swallowed the newline that ended the footnote. M18's structural
test asserts that a heading has a blank line before it, and could not see this because the line
does not start with a hash; the drift check could not see it because the file and the generator
were wrong together. It is fixed, and the guard is now no hash anywhere but the start of a
line, which fails on five lines of the previously committed file.
Acceptance
conservativeis passed at both_escalatecall sites, and B9 buys strictly more seeds than B8 on items that escalate below the ceiling.ruff checkselectsARGand passes; the kernel is not exempted, and an AST test asserts the same rule over it independently of the config.- The AST guard fails on
git show HEAD~1:src/nullius/kernel.pywith exactly one finding,_replicate(conservative). - Protocol v7 verifies, rebuilds identically, and appears in the paper as registered and not yet run.
- The paper and
FINDINGS.mdcarry the flaw and the limitation, and CI's drift check passes. - CI re-scores every
benchmark/results*.lock.jsonfound by glob — six of them, not the five the tuple named — and exits non-zero if it finds none. - No
#appears anywhere but the start of a line inFINDINGS.md, which the file committed at8460649fails on five lines.
M22 built the record correctly and drew it at about five per cent of the size it needed.
Fifteen rooms fitted in 880 by 520; a room was twenty-six units across, a person in it was
three pixels, and a fixture was a hairline. Every concept was already there and none of it was
legible. This milestone is presentation only: render.py and the templates. model.py,
map.py and ledger.py are untouched, and every test written against them still passes.
Scale was the whole problem. SCALE turns one plan unit into twelve drawing units, so the
map is authored once in map.py at the size that is right for reasoning about adjacency and
drawn at the size that has somewhere to put a console. A room is now 312 by 288 and the station
is 2872 by 1678, in a pan-and-zoom viewport: drag to pan, scroll to zoom about the cursor, click
a room to focus it and open its dashboard. It fits to screen on load, so the whole facility
reads at a glance before anybody dives in.
Rooms are places. Walls nine units thick with a lit inner edge and a doorway cut where the corridor meets them — computed from consecutive rooms in the walk, so a room that moves in the plan takes its doorways with it. A plated floor tinted toward the room's function colour, under a soft lamp in that colour that is most of what makes it feel like somewhere. Five to ten drawn fixtures each, appropriate to the work: racks and a bench in the Workshop, a long table of specimen runs on the Execution floor, sealed cabinets and one terminal in the Vault, shelving in the Archive. Drop shadows under the walls and the furniture, one light direction throughout.
Agents are people at posts. Forty-eight units tall with a distinct silhouette per Role —
the Skeptic hooded with a glass, the Custodian suited with a visor slot, the Replicator blindfold
banded and trailing a faint second outline, the Builder in a hard hat. SYSTEM is drawn as a
servitor rather than a person, because it is the deterministic control plane and giving it a face
would be the drawing making a claim the architecture does not. Each bobs on its own period,
seeded from its room's id. A room with no actor is still drawn with nobody in it.
The exits are doors. Cut into whichever outer wall faces away from the middle of the station — chosen by probing for a wall with no room behind it and no corridor through it, so moving a room moves its exits — with a lit counter plate outside each. Equal width by construction, and a test says so.
1. The captions collided because each was placed against a coordinate that happened to be free. The Vault's "no corridor crosses this line" landed on the Treasury's "0 · abandoned budget" and the two rendered as one unreadable string; "NO ACTOR STATIONED" hung outside the record's box. Both were fixable in one line each, and fixing them would have left the next pair to be found by eye.
Every string on the map now goes through _label, which measures it, truncates it to its
container and records the box it occupies — and the box is a promise rather than an estimate
because the same width is emitted as the element's textLength, so what the layout reserves is
what the browser draws, on any platform and in whichever fallback font resolves.
overlapping_labels returns the intersecting pairs and a test requires the list to be empty at
1x, 2.5x and 7x. Boxes are in world units and the camera is a uniform transform, so that is the
whole zoom range.
2. A float one unit in the last place truncated a label to fit a box built around it.
BUILDER rendered as BUILD… on a chip sized for BUILDER. The chip measured the string, added
padding to get its width, then handed the label back width − padding as its container — which
in binary is not the number it started from. The reproduction is exact: 7 × 13 × 0.6 round
tripped through +18, −18 comes back smaller than itself. _truncate now carries a tolerance
and the chip passes the measured width rather than recomputing it, which is the more important
half of the fix.
3. A dict key named after a dict method rendered as the method, for the second time.
token.values printed <built-in method values of dict object at 0x…> into an SVG animation.
The same shape put <built-in method items> into the Registry's protocol table in M22 — Jinja
resolves an attribute before a key, the page still builds, and nothing complains. Renaming it
twice is not a fix, so a test now walks every structure handed to the template and fails on any
key that shadows a dict attribute.
4. The zeroes on the exits read as a broken renderer. Four doors, four counts of nothing. They are correct — M22's finding was that no code path writes a terminal state, so nothing has ever left by one — but an honest zero still has to look deliberate. The doors are drawn unlit, and the room says in as many words that the count is a fact about the code rather than a failure of the drawing.
Acceptance
- Every test from M22 and M23 still passes; the data layer was not touched.
- A room's interior holds at least five drawn fixtures, all of them inside its floor, and every stationed role renders a figure of at least sixteen units with its own silhouette. Tested.
- Pan, zoom and click-to-focus work, and the station fits to screen on load.
- No two text elements on the map overlap, at any zoom the viewport reaches. Tested against the measured boxes rather than by eye.
- Every label fits the container it was given, and the exits' counter plates never sit over a room's floor. Tested.
- Still one self-contained file — no CDN, no external font, no remote image — and it still refuses to build on input that does not verify.
prefers-reduced-motionstops every animation and the pause control starts pressed when it is set.
M24 drew the station at a size worth looking at and then framed it as a diagram: a map in one
column, a permanent dashboard in the other, room labels printed on the floors, and staff who
stood still. This milestone makes it a map you play with. Presentation only again — model.py,
map.py and ledger.py are untouched and every test written against them still passes.
The label came off the floor. Each room is named by a callout card that floats beside it,
with a numbered badge, the room's name, chips for the roles stationed there and the states it
owns, a status lamp and one word for what it is doing: WORKING, IDLE, NO DATA, LOCKED or
SEALED. Every one of those words is read off the assembled record — a locked room is one whose
feature is unbuilt, a sealed one has no corridor into it by design, an idle one is a room the arm
on display does not engage. Moving the label out gave the interior back its top third, which is
now a third row of furniture.
Cards are placed by search, not by hand. For each room the layout sweeps every position around it — four sides, three distances, nine slides along each wall — discards any that leaves the viewBox, sorts what is left by distance from the room, and takes the nearest that hits nothing already on the map. The result is that each card sits directly above or beside the room it names, and no two of them touch. Screening's card stacks above its own exit plate because that is the only clear space its room has.
The map is the page. Full width, up to 88% of the viewport height, with a head-up display over it: the station's identity top-left, zoom and pause top-right, the legend bottom-left, and a department roster down the right — fourteen rows with a status pip each, which is also how you reach a room without hunting for it on the map. Clicking a room, its card or its roster row opens the dashboard as a sheet over the map rather than in a column beside it, so the dossier gets the width it deserves and the map keeps all of its own.
People walk. Every stationed role paces a patrol on the clear floor in front of its workstations, with its own stretch of the room, its own lane and its own phase. They stay in their rooms: the token is what moves through the station, and a role walking the corridor would say something about the architecture that is not true.
1. A search whose failure mode is "use the answer it started with" is not a search. The first card placer had six fixed slots and fell back to the first one when all six were rejected. It silently put Screening's card over its own exit plate, and then — once the sweep was widened — put the Oracle's card over the Vault. Both are the same bug: the fallback was a guess dressed as a result. It now takes the slot with the least overlap, which is a defensible answer, and a test asserts no card sits over any room, so a future crowding shows up as a failure rather than as a drawing.
2. The right edge of the plan had no margin, and the sealed column had nowhere to put its cards. The world's margins were symmetric because that is the obvious way to write them. The two edges are asked for different things: the record's counter plates hang off the left, and the Vault, the Oracle, the Archive and the Treasury are stacked against the right with sixty units between them and no room on any side. Asymmetric margins, and the column's cards sit outside it where they belong.
3. Two actors in one room walked the same lane and drew as one shape. Screening's Director and Literature, and the Registry's Designer and the system servitor, were given the same stretch of floor with different phases — which keeps them apart most of the time and merges them the rest of it. Each now gets its own slice of the width, so they cannot occupy the same place whatever the phase. A test asserts the patrols are disjoint.
4. A card wider than the gap between two rooms cannot sit above its own room. The room pitch is 348 units; the first cards were up to 520. Each pushed the next along the row until Screening's label was hanging over Registry, three rooms from the thing it named, with a leader line making it traceable rather than readable. Cards are capped at the pitch, and the index moved into a badge so the name has the width instead.
Acceptance
- Every test from M22, M23 and M24 still passes; the data layer was not touched.
- Every room is named by exactly one card, the card carries the room's own name, and no card sits over any room or leaves the viewBox. Tested.
- Every card's status word is derived from the record, and matches what the record says about that room. Tested against all five states.
- Every stationed role walks a patrol that stays inside its own room's floor and never shares floor with another actor. Tested.
- The dossier holds a panel for every room and opens over the map; the roster lists every department. Tested.
- No two labels on the map overlap, at any zoom. Still tested, now with the cards in it.
- Still one self-contained file, still refuses to build on input that does not verify, and
prefers-reduced-motionstill stops every animation including the walking.
Two milestones of drawing, and clicking a room did nothing at all. Fixed, and then the dossier
was made worth opening: eleven actors redrawn as their own builds, and a dashboard with pages,
an arm switch and a filterable record of every item. Presentation only again — model.py,
map.py and ledger.py are untouched.
The map answers now. Click a room, its card or its roster row and its dossier opens over the map; Escape or the scrim closes it.
The actors are builds, not silhouettes. The Director in a long coat with a peaked cap and a
tablet; Literature with round spectacles carrying a stack of books on a satchel strap; the
Builder in a hard hat with a lamp, a tool belt and a wrench; the Designer in an apron with a
drafting triangle, a T-square and a pencil behind the ear; the Analyst in a headset reading a
floating panel of bars; the Skeptic hooded with a glass; the Custodian sealed into a suit whose
helmet has a visor slot instead of a face, with a keyring; the Replicator blindfold-banded and
dragging a faint copy of itself; the Reviewer in a mantle with a stamp; the Theorist trailing a
scarf with a slate. SYSTEM is still a servitor rather than a person, for the same reason as
before. A test draws each on its own and requires no two to come out the same.
The dossier is a console. Six pages where there was one — overview, figures, arm scores, items, provenance, and protocols or contrasts where the room has them — with an arm switch across the top. The switch is the interesting part: it changes which recorded arm every panel is describing, and the map with it. The route the tokens walk, which rooms are lit, every card's status word and lamp, and the roster all follow, because a dossier that disagreed with the drawing behind it would be worse than either alone.
The items page is the arm's own record — one row per bank item per pass, with what it answered, what is true, whether that scored, seeds bought, dollars spent, realised against true effect and the margin to the nearest verdict boundary. Filter by outcome, search by item, sort by any column. The provenance page lists every figure in the room beside the artifact it was read out of, which is the claim this project makes about itself, made checkable in the place a reader is standing.
Every arm is assembled by re-entering assemble() rather than by reconstructing its figures, so
the switch shows what the record says and cannot drift from it. A test asserts that each arm's
figures equal the ones that arm produces when assembled on its own.
1. An invisible sheet of glass over the whole map. The tokens are drawn after the rooms and carry a bloom filter, and a filter's region is much larger than the thing it blurs — the group's bounding box is the entire route, inflated by the filter margins. With hit testing left on, that one group sat over every room on the map and ate every click. The page rendered perfectly and simply did not respond, which is the worst shape a bug can have.
Every layer drawn above the rooms that is not itself a target now declines hit testing, and a test reads the markup and fails if one of them stops. The failure was invisible by construction, so it needed a check that does not depend on anybody noticing.
2. Capturing the pointer on pointerdown cancels the click. The second half of the same bug,
and it would have survived the first fix. Pan was implemented by capturing the pointer as soon as
a button went down; the browser then retargets the subsequent click to the capturing element,
so a click on a room arrived at the viewport instead. Capture is now taken only once the pointer
has actually moved, and the click is handled by delegation from the viewport, which is immune to
retargeting either way.
3. A drag threshold that never reset. moved accumulated across a drag and was only zeroed
on the next pointerdown, so the guard that suppresses a click at the end of a pan was carrying
state into the next interaction. It is measured from the start of each press now.
Acceptance
- Every test from M22 through M25 still passes; the data layer was not touched.
- No layer drawn above the rooms accepts hit testing. Tested, and the test was proven to fail with the fix removed before it was believed.
- Every id the page's script looks up exists in the document it ships in. Tested — a generated page whose script and markup disagree fails silently in a browser and nowhere else.
- The page carries every arm of the protocol on display, and each arm's figures are the ones that arm produces when assembled alone. Tested.
- The items table is the recorded outcomes, one row per item per pass, unsummarised. Tested.
- No two roles are drawn the same way. Tested by rendering each build on its own.
- Still one self-contained file, still refuses to build on input that does not verify.
Presentation only again — model.py, map.py and ledger.py untouched.
The corridor was a stroke through the room centres. Wide, dark, and a line on a diagram rather than somewhere you could walk. The runs of floor that are genuinely outside a room are now computed from the gap between two consecutive shells and drawn as sections: walls down both sides, deck plating, a dashed guide down the middle and a light overhead. They line up with the doorways because they are measured from the same gap the doorways were cut for. Inside a room the walk continues as a band of darker plating across its floor, so the route reads as one run rather than as rooms with a line between them. Each threshold gets hazard chevrons.
Rooms went from seven objects to twelve. A fittings band against the top wall — vents, conduit runs with a pilot light, an illuminated sign over the door — a third row of furniture, and loose props tucked into the margins the patrol does not use: chairs, barrels, a cart, a coil of cables, a floor hatch. Eleven new fixture kinds. Each room also carries its number as a large faint floor stencil, which is the same index its callout card shows.
The actors have arms. Ten builds gained shoulders, sleeves in the room's colour, and hands — which is what was missing when a figure was holding a tablet or a wrench with nothing to hold it with.
1. Two bands were computed from different origins and landed on each other. The overhead light strips were positioned from the top of the floor and the new wall fittings from the content origin, and at the room's height those two happen to coincide — the lights were drawn inside the vents. Neither was wrong on its own. The lights now run across the room where a ceiling light would be, rather than along the wall where the fittings are.
2. A patch script that asserts its way down a file leaves the file half-written. The substitution for the floor stencil did not match, the assertion fired, and because the write is at the end nothing at all was applied — but the earlier substitutions had already succeeded against the in-memory copy, so the run looked partial and was in fact a no-op. Worth recording because the failure reads as the opposite of what it is: an aborted all-or-nothing patch is safer than a partial one, and it is easy to spend a while looking for changes that were never written.
Acceptance
- Every test from M22 through M26 still passes.
- A room holds at least five drawn fixtures, all inside its floor — now twelve to fourteen. Tested.
- No two labels overlap, including the new floor stencils, at any zoom. Tested.
- Still one self-contained file; still refuses to build on input that does not verify.
Presentation only for the third time — model.py, map.py and ledger.py untouched.
Every object in the station was made of the same two greys. --fix and --fix-2, plus the
room's own accent for anything that lit up. That is why a workbench, a filing cabinet and a
reactor were the same rectangle at different proportions, and why a room read as a plan of a room
rather than as a place. Things are now made of something: steel, enamel, wood, brass, glass,
rubber, painted metal, each with a lit side and a shadowed one, and a handful of fittings light up
in a colour the room does not get to choose — a pass bin is green wherever it stands and a coolant
line is cold everywhere.
And the kit was generic. Nine of the fourteen rooms held a rack, a cabinet and a shelf, which says the nine departments do the same work — the one thing the map exists to deny. Each room now owns equipment nobody else has: a drafting table and a corkboard strung with red thread in Drafting; a scanner arch and three sorting bins in Screening; a seal press and a wall of numbered drawers in Registry; pegboards, parts bins and coolant valves in the Workshop; a core in a cage on the Execution floor; plot walls and an oscilloscope in Analysis; targets and a training dummy in Challenge; a case of blanks and a key safe in the Blind room; a reading lamp and a podium in Review; a press and tape reels in the Record; two vault doors and bullion on pallets in the Vault; a teller counter and a balance in the Treasury; card catalogues in the Archive; an orb in a gimbal and two dishes in the Oracle. Thirty-two new kinds of object; twelve to fifteen in every room.
The corridor got wider and got equipment. Hazard-striped edges, grating across the run, rivets along the wall panels, direction arrows down the middle, a pipe run with a junction box, pools of light under the lamps, and the run's own number stencilled on the deck. Each of those is placed from the hall's index, so the run between Drafting and Screening is not the run between Screening and Registry drawn a second time.
The actors got volume. Boots with soles, a far leg in shadow, a torso with a lit shoulder edge and a shaded side, sleeves ending in gloved hands, a face with a brow, an ear and a shadowed cheek — and a chest insignia whose shape is the role's own.
1. .glowpane animates opacity, so the opacity you wrote is not the opacity you get. The
class exists to make a screen breathe between 0.7 and 1. Put it on a twelve-percent halo over the
bullion and the halo is composited at eighty-five percent: a solid lens of gold sitting on top of
the bars. The animation wins because it is the animated value, not the presentation attribute.
Anything that wants to be faint has to not be told to hum. Soft edges now come from a radial
gradient, which is also what the three hard-edged elliptical smudges in every room turned out to
need.
2. The arms went on before the coat, so the hands were inside it. Boots, legs and arms were drawn first and the torso over the top, which is right at the shoulder and wrong at the wrist: the hand sits a hair inside the coat's own silhouette and disappears, leaving every figure with two stubs. It was invisible while the figures were flat and obvious the moment they had gloves to lose. Arms now go on after the body and before whatever the figure is carrying.
Acceptance
- Every test from M22 through M27 still passes.
- Every kind a kit names has a branch in the fixture macro and a material. Tested — a kind without one falls through to a grey box that looks like furniture from a distance, which is exactly how the station got here.
- No two rooms are furnished the same way; each owns at least one thing nobody else has. Tested.
- Every room is drawn in at least three materials and lit by at least two colours. Tested.
- Each run of corridor knows which run it is. Tested.
- Still one self-contained file; still refuses to build on input that does not verify.
Presentation, plus twelve room names. model.py and ledger.py untouched; map.py changed only
where it holds the string a room is called.
The map was a diagram wearing a drawing. Fourteen callout cards, a roster, a header, a legend,
counter plates and every caption on the station were on screen at once, and under all of it was
the thing worth looking at. At rest the map now carries no writing at all — rooms, fittings,
people, and the number painted on each floor, which is something painted on the room rather than
writing about it. Hovering a room is the question and a peek is the answer: which department, who
is stationed there, what is behind the number, what it is doing on the arm on display. Clicking
opens the dossier as before. labels puts the whole annotated layer back, and the fit follows —
bare, the view frames the building; annotated, it frames the world the callouts live in.
And everything in it was flat. Objects were filled and stroked and that was all, which is why the station read as printed rather than built. Every fixture now goes through one lighting pass: a bevel taken off the shape's own alpha, a specular hit from up and to the left, and the shadow it casts on the floor — so all fifty kinds are lit without any of them knowing about it. The walls are drawn as walls: an outer face, a cap, an inner face and bolts, with the floor sunk inside them and the shade they throw falling across it. Floors gained a painted work zone, wear, bolts and drains; the ground the station stands on gained a depth gradient and a hundred and fifty stars.
Twelve rooms are named for places rather than for activities. Analysis is what you do; the Analysis Room is where you stand. Design Room, Screening Room, Registry Room, Development Workshop, Experiment Floor, Analysis Room, Challenge Chamber, Blind Testing Room, Review Room, Records Room, Resource Room, Archive Room. The Vault and the Oracle keep their names, because those two are not rooms you work in.
1. A specular pass that is correct is still a specular pass that is wrong. The first relief filter had a wide blur and a low exponent, which spreads the highlight across the whole of a shape instead of hugging its edge. Every material went milky: the workbench stopped being wood and the parts bins went pastel. The fix is not less light, it is a tighter light — a small blur and a high specular exponent — and the material colours had to come down half a step anyway, because a surface that is now lit does not also need to be bright.
2. A label that does not fit is not allowed to lie about the name. DEVELOPMENT WORKSHOP is
wider than the widest card the room pitch allows, and the label system's answer to that was to
truncate, so the card beside the Development Workshop said DEVELOPMENT WORKSH…. Every other
string on the map may be clipped; the name of the room the card points at may not. Names are now
set smaller until they fit, and the test that caught it is the one that asks each card to carry
its room's name in full.
Acceptance
- Every test from M22 through M28 still passes.
- The callouts, the captions, the counter plates and the roster are hidden at rest and shown by
labels; the floor stencil is not. Tested against the stylesheet. - Hovering a room names it, from the same record the dossier reads. Tested.
- The bare map is framed to the building, and every room is inside that frame. Tested.
- Every room but the Vault and the Oracle is named for a place, and the page carries every name in full. Tested.
- Still one self-contained file; still refuses to build on input that does not verify.
Presentation only. model.py, map.py and ledger.py untouched.
Every object in the station was an axis-aligned rectangle lying flat. Three milestones went into the kit — thematic equipment in M28, materials and lighting in M29 — and the map still read as coloured squares inside a bigger square, because none of that addressed the thing that was actually wrong. Nothing in the drawing distinguished a cabinet from a mark painted on the deck. Detail added to a flat rectangle makes a busier flat rectangle.
Every fixture now has a height, and is drawn as a solid. It arrives with f.z, which is how
much of its box is the face you look at rather than the top you look down on, and one shared
solid() draws it: a cast shadow, a front face in its own shadow darkening towards the floor, a
top face in the light, a bright seam where the two meet and a dark contour all the way round.
Cylinders — barrels, the reactor core — go through drum() and get a lid and a curved body.
Detail then goes on the two faces, which is what makes an object legible rather than merely
present: a rack has a vented lid and blade units with lights in its face; the sorting bins are
tubs with coloured lids; the parts wall is bins tilted towards you; the file walls are drawers
with brass pulls and one left open; the press has a wheel and a sheet coming off it; a stool is a
seat on a post with legs under it.
The height is a fraction of the box the row already allotted, so standing up costs the drawing no floor space and nothing can stand into the row behind it.
1. A specular filter and hand-drawn faces are the same job done twice. M29's relief pass existed to fake shading on flat shapes. Applied over faces that are already lit it did what it always does to a lit surface — added light — and every material went milky again, exactly as it had at the start of M29. The specular came off; the filter now does only the part that is still needed, which is putting the object on the floor.
2. The figures became the flat thing. They were fine while the furniture was flat too. The moment the furniture had volume, the people were the only cut-outs left in the room, and because a figure is drawn from many small parts it has no single silhouette to stroke. The contour is taken from whatever silhouette each one happens to have, by dilating its own alpha — which is also the only way to outline a shape that changes as it walks.
Acceptance
- Every test from M22 through M29 still passes.
- Every fixture has a height, and the height is the one the table says. Tested.
- A solid is drawn inside the box its row allotted. Tested — a thing that stands up must not stand into the row behind it.
- The lighting language goes through the shared helpers, and the specular pass it replaced is gone from the page. Tested.
- Still one self-contained file; still refuses to build on input that does not verify.
Presentation only. model.py, map.py and ledger.py untouched.
The camera is on its side. Every room was a floor you looked down at, with its furniture laid out in four bands of depth. A room is now a chamber you look into: a ceiling with fittings hung from it, a back wall with a service run across it, and a floor slab that everything stands on. There is one axis left and it is up, so how tall a thing is has become the whole of what says what it is — a wall of filing drawers is three quarters of the chamber, a stool is a quarter of it — and the rows are divided in proportion to how wide each thing deserves to be rather than evenly, because a row of identical columns reads as a chart rather than as a room somebody laid out. Everything either stands on the ground line or is fixed above it. There is no third case.
And the black between the rooms is the building now. Fourteen lit boxes floating in nothing says the departments are all there is and that between them is nothing, which is false about any institution and specifically false about this one. Between the two rows of the pipeline there is a service deck: a truss, a pipe run, pressure vessels and valve wheels. Down the side of the sealed wing there is a trunk with flanges and a ladder. Between the sealed rooms there are short service runs. Under it all is the plant hall — columns and cross-bracing, three tanks with their access ladders, heat exchangers, a gantry rail with a trolley on it, and the cable trays that feed the place. Every region is derived from where the rooms actually are, so moving a room moves the building with it rather than leaving a gantry hanging over a floor.
None of the plant records anything, and none of it is a department. So it carries no number anywhere, nothing on it can be clicked, and there is a test that says so.
1. A kit says what is in a room, not where. The furniture tables named a wall row, a back row,
a middle row and a front row, which was a description of the old point of view baked into the
data. Turning the camera made three of those four meaningless — and plotwall sat in Design's
middle row, so with the wall rule applied only to the back row it ended up standing on the
floor, which is a board leaning against a wall and a different claim about the room. Anything that
belongs on a wall is now taken out of whichever row named it, wherever the kit happened to put it.
2. The test that pinned the cap was pinning the point of view. M30's z was the height of a
thing's front face and its complement was the top you looked down on. In elevation the same number
means the opposite thing: almost all of an object is its face, and the cap is the sliver you still
catch from a little above. The test asserting the ratio had to be rewritten, and it was right that
it failed — it was the only thing in the suite that knew which way the camera pointed.
Acceptance
- Every test from M22 through M30 still passes, one of them rewritten for the new camera.
- Everything in a room stands on its ground line or hangs above it, and stays inside its chamber. Tested.
- A room is drawn at three or more different heights, and every height is the one the table says. Tested.
- The people stand on the same floor as the furniture. Tested — there are no lanes any more.
- The plant fills the space between the rooms, never overlaps one, states no number and cannot be clicked. Tested.
- Still one self-contained file; still refuses to build on input that does not verify.
Presentation only. model.py, map.py and ledger.py untouched.
The people were a gif. Each one traced a few pixels of path back and forth, forever, at the same speed, whatever was happening. That is decoration, and decoration on a page whose whole claim is that nothing on it is decorative. An actor now walks between the stations of its own room — the room's own fixtures, chosen from its own stretch of floor — stops at each one, leans into the work, and turns to face the way it is going. Where a stretch of floor has nothing to work at, the actor paces it rather than miming at a spot where there is nothing: two stations a hand's width apart is a figure shuffling on the spot, which reads as a broken animation rather than as somebody busy, so below a minimum spread the route falls back to walking.
And the animation now means something. An arm that does not engage a room has nobody working in it, so switching arms changes who is at work — the actors in the rooms that arm leaves out stand at their posts. That is the same fact the route, the opacity and the dossier already report, said a fourth way, and it is the difference between a map that moves and a map that is showing you a system.
Everybody says who they are. A nameplate rides above each figure with the role's own name on it, outside the group that mirrors, so it stays the right way round when its owner turns.
The two thin places are filled. Review and The Oracle had kits written for a layout with four rows of depth and supplied too few things to stand on a floor; both gained furniture. The plant hall had three tanks and two exchangers clustered to the left, so its right-hand third read as the building running out — a row of nine switchgear and skid units now runs the length of its deck.
1. A negative delay and a frozen clock look exactly like a broken animation. Sampling the
actor's transform in the browser returned the same number sixteen times over eleven seconds. The
keyframes were right, getAnimations() said running, and the value never moved — because the
pane was not compositing, so the document timeline was not advancing at all. Driving
animation.currentTime by hand proved the motion in one step. Worth recording: when an animation
looks stuck, check the clock before the keyframes.
2. tag was already taken. The nameplate went in as class="tag", which is also the class on
the dossier's backing chip — so the count of nameplates on the page was one more than the number
of people in the building. The test that caught it was counting a class name rather than a thing,
which is exactly why it caught it.
Acceptance
- Every test from M22 through M31 still passes; three that read the old motion path were rewritten against what an actor does now.
- Every actor's stations are inside its own room, in order, and far enough apart that nobody shuffles on the spot. Tested.
- Two actors in a room never cross. Tested.
- At least two thirds of actors are stationed at a real fixture rather than pacing, and no actor is half at a station. Tested.
- An actor in a room this arm does not engage stands at its first station, with the animation off. Tested against both the stylesheet and the script.
- Every actor carries its own name. Tested.
- Still one self-contained file; still refuses to build on input that does not verify.
Presentation, plus one new module of prose. model.py, map.py and ledger.py untouched.
Every dossier opened on a charter. The charters are exact and they are written for somebody who already knows what a preregistration is:
Detectors and the Skeptic raise typed objections, each carrying the experiment that would tell it apart from the claim it disputes.
That is no use at all to a reader who has just clicked on a room. Every department now opens on a brief: what the room is for in one sentence, what happens in it as a numbered list, who works there with a job title an outsider would recognise, what it is doing on the arm on display, what it has recorded, and what has not happened yet. The exact wording is one click away under show the exact rule, because plain language is a summary and a summary loses things.
And every department got the tab for the thing it, and no other, does. What gets in (Design), what happens to the losers (Screening), the lock (Registry), the bundle (Workshop), the sandbox (Experiment Floor), who writes the numbers (Analysis), objections (Challenge), blindness (Blind Testing), why it is dark (Review), the four doors (Records), custody (the Vault), the money (Resource), what is remembered (Archive), ground truth (the Oracle). Fourteen departments, fourteen tabs, none of them a house style applied fourteen times.
The prose is the page's one exception, and it is fenced. station/brief.py is hand-written
and says so at the top. The rule that keeps the exception safe is that it may not contain a
number — not one, anywhere, checked by a function in the module and by the test that already
guards every other piece of hand-written prose on the page. Every quantity on a brief is filled in
by the page from the record. The file can be wrong about what the institution is for, which is
a thing a person wrote down and can be argued with; it cannot be wrong about what the institution
did.
1. The station's own tests had become the slowest thing in the suite, by an order of
magnitude. Adding six tests took tests/test_station.py from seventy seconds to sixteen
minutes, which is not a thing six tests can do. --durations put thirty-one seconds against
setup on twenty-one different tests: each one asked for a tmp_path and wrote a 1.6MB page into
it, and the cost was in the fixture rather than in anything being tested. The page is
deterministic — there is a test at the top of the file that asserts exactly that — so it is now
built once and cached. Eighty-three tests in thirty-four seconds, which is faster than the
suite was before this milestone with six fewer tests in it. Worth recording because the symptom
pointed at the wrong place entirely: the new tests looked like the cause and were merely the straw.
2. open() set the tab twice. The dossier kept landing on the old first tab however the brief
was wired, because a line further down open() reset tab = 'overview' unconditionally — a line
written when there was only ever one first tab. Two statements setting the same variable four
lines apart, the second one silently winning.
Acceptance
- Every test from M22 through M32 still passes.
- Every room has a brief and every role has a plain-language description. Tested.
- No piece of that prose states a figure. Tested twice — by the module's own check and by the suite's existing one-place guard, which the brief now joins.
- Every department has a tab no other department has, present in the strip and in the panel, with at least two sections. Tested.
- A dossier opens on the brief, and the exact rule is one click away in every one of them. Tested.
- The brief's numbers come from the arm's own record rather than from the prose. Tested.
- Still one self-contained file; still refuses to build on input that does not verify.
Presentation, plus one room on the plan. model.py and ledger.py untouched; map.py gained a
room and the coordinates of the others moved around it.
The station had no front door. Opening it gave you fourteen departments and a pipeline to guess your way along, and the first thing anybody wants — what is this, who is in it, what has it done — was not anywhere. There is now a Control Room: the first room, the largest thing on the plan by seven times, sitting across the middle with corridors up to the four departments above it, down to the four below, east to the pipeline's turn, and on to the spine that serves the sealed wing. Its brief is the only one whose subject is the project rather than a department. It counts the institution at a glance — departments, kinds of actor, arms run, recorded passes, rooms that do not exist yet — carries the arm's whole scored result, and ends in a grid of fourteen buttons that take you into any of the others.
No new role was invented for it. The rule this project runs on is that departments, roles and
states come out of db/enums.py and are derived rather than declared, and a room needs a reason to
have somebody in it. The Control Room is staffed by the control plane, because the control plane is
the thing that wrote every event the room reports. It owns no state of the research machine, it is
on no route, and nothing passes through it — it is a view, and the map says so by leaving it off
the corridor.
And every dossier was the same dossier. One stylesheet applied fourteen times meant reading the title to know where you were. A department's sheet is now set in its own face, its own hue and one of five frames: a reading room lays its sections out in a single generous column, a board sets them abreast under coloured rules, a bay boxes each one like something bolted to a wall, a ledger runs a left column of headings against a right column of entries, and a console puts them on a dark bank of readouts. The Registry is monospaced because it deals in hashes; Records is slab-serif because it is a ledger; the Design Room is a serif reading room; the Workshop and the Challenge Chamber are condensed and boxed. Every face is a stack of things already on the machine, because the page still has to work with the network unplugged.
Each department also gained a second tab of its own, and the hub four. Thirty-two tabs across fifteen rooms, written out of the same data the sections are so that adding one cannot leave it out of the strip.
1. A room that big has nowhere to hang its label. The callout placer searches four sides, three distances and nine slides, filters to what is inside the world, and takes the least-bad slot if nothing is free. The Control Room is a hundred and twenty units wide across the middle of the plan: above it is a row of departments, below it is another, and the margin to its left was too narrow to hold a card. So it took the least-bad slot, which was on top of another room — and the test written in M25 for exactly that failure caught it immediately. The margin is wider now. Worth recording because the placer did not fail; it did what it was told, and what it was told had stopped being possible.
2. Giving the hub a good kit took the Analysis Room's identity. Every department is tested to own at least one kind of object nobody else has. The Control Room wanted screens and plot walls and consoles — and the moment it had a screen, Analysis had nothing of its own, because a screen was the only thing that had been Analysis's alone. The hub got a signature instead: a wall-spanning status board, which is a better thing for it to have anyway. A test that only checks uniqueness tells you something has been taken; it does not tell you what to do about it, and the right answer was not to give the hub less.
Acceptance
- Every test from M22 through M33 still passes.
- The hub is first, is the largest room, owns no state, and is on no route. Tested.
- It has a corridor to every room that can be walked to and none into the two that cannot. Tested.
- At least five faces and four frames are in use, and no face carries more than four departments. Tested against the stylesheet.
- Every department has at least two tabs of its own, all of them in the strip and in the panel. Tested.
- The hub's figures are counted across the map and the record, and it points at the other fourteen. Tested.
- Still one self-contained file; still refuses to build on input that does not verify.
Presentation only. model.py, map.py and ledger.py untouched.
Worked from a list of outside suggestions, kept the ones that were true and refused the one that was not.
Every room now says what it is doing, on its own wall. The map has always known — it was on
the callout card, which M29 took off along with all the other writing, and a facility whose only
sign of trouble is a card you have to switch on is a facility that always looks like it is going
well. Fourteen plates, each with a lamp coloured by what is behind the room and a word from the
same call the dossier uses: WORKING, NO DATA, IDLE, LOCKED, SEALED. Switching arms moves
every one of them. The Review Room reads LOCKED in red from across the floor, which is the
honest state of this project and is now visible without opening anything.
The Control Room shows the walk it reports on. It described a pipeline and drew none. There is now a strip of ten numbered blocks on its wall, in the order a hypothesis meets them, each lit by what that stage is doing on the arm on display and joined by the line the hypothesis follows.
And the building moves. Not more decoration — the same building, running. A car goes up and down each shaft, packets travel every corridor, and the screens change what they are showing. Everything new stops for the pause button and for a reader whose machine has asked for less motion, which is now tested rather than remembered.
Actors wear their room's number. 05 BUILDER rather than BUILDER, so an actor and its
department are one fact seen twice instead of two things to hold in your head.
A mission panel — MISSION 042, discover robust predictors of X, 78% complete, 12 active, 3 review, 2 failed. It would look superb and every number in it would be invented. This station shows a finished, recorded run; there is no mission in progress, no percentage of anything, and no queue. The rule the whole page is built on is that no number reaches it except by being read from a verified artifact, and a progress bar is a number. What the idea was really asking for — the building should have a visible current purpose — is answered by the hub's own brief, which says which arm is on display and what it actually scored.
1. A patch script that asserts its way down two files leaves the second one untouched. The
last substitution in the run targeted station.html and the text it wanted was in agents.html,
so it raised — and because the write is at the end, everything the script had already done to
station.html in memory went with it, while parts.html, written earlier in the same run, had
landed. Half the milestone appeared to apply. The same failure was recorded in M27 and it caught
me again, in the same shape, for the same reason: the write is at the end and the assertion is not.
2. The pipeline strip was drawn behind the furniture it describes. Placed at a fraction of the chamber that looked like the middle of the wall, it landed on the tops of the consoles standing on the floor. In elevation there is exactly one free band — between the boards fixed to the wall and the tops of the things standing under them — and the test now asserts the strip is in it rather than trusting the fraction.
Acceptance
- Every test from M22 through M34 still passes.
- Every room carries a plate inside its own chamber whose word comes from the same call the dossier uses, and the set of words on the map is not uniformly cheerful. Tested.
- The hub's strip is the walk, in order, numbered as the rooms are, and sits clear of both the wall boards and the furniture. Tested.
- Every actor wears its room's number. Tested.
- The lift, the packets and the screens all stop for the pause button and for reduced motion. Tested against the stylesheet.
- Still one self-contained file; still refuses to build on input that does not verify.
mainis always green. Every milestone lands as a PR with CI passing on Linux and Windows.- Invariants get property tests, not unit tests. If a rule in
docs/03can be stated as "no sequence of API calls can produce X", it is tested that way. - No milestone is "done" because the code exists. It is done when its acceptance criteria are automated and green.
- A test that cannot fail is worse than no test. M1 shipped two tests that passed without exercising anything: one bound a UUID in a form the database does not store (matching zero rows, so the trigger never fired), another attempted
UPDATEon an empty table (where a row-level trigger never fires). Both now assert their own preconditions. Any test asserting that an operation is refused must first prove the operation would otherwise have done something. - Deviations from
docs/are ADRs, not silent drift. The design documents are not edited to match the code; the ADR records why they diverged. - The project's own claims follow its own rules. The benchmark protocol is preregistered with a hash before the ladder is run, and negative results are reported.
- API key —
ANTHROPIC_API_KEYneeded for the first live run at the end of M6. Everything before that is mock-driven. - Docker — needed only for M12 code generation, or earlier if you want the strong sandbox. Requires WSL2 + Docker Desktop and a reboot.
- The DGPs in M4. The structural causal models are the ground truth this project is scored against. They are designed by a person and implemented by the machine — never the reverse.
- Benchmark preregistration — a human signs off before the B0–B7 ladder runs.