Roadmap and status - UnitBuilds-CC/Merkles-World GitHub Wiki

Roadmap & status

Authoritative version: docs/11-roadmap.md.

Where the project actually is

Gate State
Architecture specified done β€” design level only
Engine code Phase 0 code plus the first behaviour and the first pipeline β€” contracts/, crates/coords, crates/ledger (the A-10 hasher and the epoch commit path) and crates/memory (slabs). Still no engine runtime: that is Phase 1
SPEC conflicts (doc 13) 9 registered β€” 4 locked (SPEC-02 β†’ ADR-017; SPEC-01, SPEC-03, SPEC-09 β†’ ADR-029, 2026-10-11), 4 proposals, 1 open (SPEC-07)
Open questions 61 tracked (six raised by measurement since 2026-10-12 β€” OQ-56 to OQ-61, each opened by a number rather than by reading prose; the licensing half of (licensing half of OQ-43 decided 2026-10-11: MIT OR Apache-2.0; OQ-45/46 raised by drafting A-03/A-08; OQ-47/48 by the two sign-offs below). OQ-44 went live 2026-10-12 when the BLAKE3 pass landed: its bytes-in/digest-out entry point has no type refusing an f32
Blocking artifacts A-01…A-10 A-01 contracts/coords.rs frozen 2026-10-11; A-10 contracts/tree.rs frozen 2026-10-12 on ADR-030 and published vectors; A-03 contracts/delta.rs and A-08 contracts/tx.rs drafted 2026-10-11, both DRAFT; A-09 contracts/errors.rs drafted under a DRAFT header; A-02/A-04/A-05/A-06/A-07 unwritten
Digest + GPU substrate ADR-028 (BLAKE3-256) and ADR-026 (wgpu) signed off 2026-10-11 β€” the two proposed ADRs that gated any engine code; they are the workspace's first external dependencies
Research spikes S1–S10 partly run, and only the CPU-able halves: S1's march-step law, S2 in three parts, S3 across three architectures plus the scalar-hash, WASM-SIMD and x86-codegen axes, S6 wherever a runner has a device, S7's first propagation run, S9's wgpu cost. What has not run is anything that needs a GPU attached (the R1/R2 halves) or a ruling first (S5's threshold, S4's upload).
Validation gates C1–C4 run in CI (x86-64 Linux / x86-64 Windows / ARM64 macOS + a wasm32 compile)
Public repo main published, wiki mirrored, licensed MIT OR Apache-2.0

Sequencing principle

The design is deep and the temptation is to build the coolest layer first. Resist it: three contracts gate almost everything downstream, and one research question can invalidate an entire subsystem. So the order is

SPEC/OQ resolution  β†’  spikes S1–S10  β†’  contracts/ frozen  β†’  P0 harness  β†’  P1 core loop
   (doc 13)             (below)          (doc 12)              (doc 12)         (docs 02–09)

Blocking artifacts

Small, reviewable files that must exist as artifacts before implementation. Writing them is how the open questions get answered, so they are the only thing writable while questions are still open.

ID Artifact Blocked by
A-01 contracts/coords.rs β€” GlobalVoxelPos, RegionKey, LocalOffset, SlabKey, SectorKey, AabbI64, octree key derivation, region_key() / local_offset() frozen 2026-10-11. Was SPEC-01/03/06 + SPEC-09: ADR-029 locks SPEC-09, pins SPEC-01/03, and reclassifies SPEC-06 as doc 03's allocation choice rather than an addressing constraint
A-02 contracts/ledger-api.wit β€” frozen v1 host surface OQ-34, ADR-013
A-03 delta record layout β€” the packed u64, region-slot table, epoch header drafted 2026-10-11 as contracts/delta.rs (DRAFT) on ADR-029's |16 slot|36 intra-region|12 payload|; gate P1.1 already runs against it as p1_1_delta_record_round_trip. Its freeze waits on OQ-45 (what the 12 payload bits mean) and on SPEC-06's slab capacity, which spike S2 must measure
A-04 macro field schema + rule library interfaces OQ-01, OQ-08
A-05 event tag registry (enumerated, versioned) OQ-08, gate SM1 / spike S7
A-06 circuit graph IR + stateful node interface OQ-02
A-07 SDF node grammar schema (.fbs + interpreter signature) ADR-012
A-08 ActionTx wire layout drafted 2026-10-11 as contracts/tx.rs (DRAFT) β€” doc 10's envelope, split as ADR-015 splits its wire-delta row: Target is the address, TxPayload the value, and a slab mutation's address+value is an A-03 record. Freeze waits on OQ-46 (what the ids are, how wide, which scheme signs) and on OQ-30/OQ-23 for the serialized byte format
A-09 error taxonomy + determinism violation codes doc 12
A-10 Merkle tree shape β€” node kinds, arity, child order, empty rules, canonical byte order, epoch-root composition drafted 2026-10-11 as contracts/tree.rs on ADR-030, the half ADR-028 left open, executed 2026-10-12 by crates/ledger, and frozen 2026-10-12. Writing the hasher settled two halves the ADR had left as prose: which EpochHeader fields the epoch root hashes (epoch_index, record_count β€” the other two roots enter once, from the header), and the false claim that lexicographic sort is the octree's walk order (it is Z-order; the root is position-keyed, so neither order moves it)

Spikes (throwaway, timeboxed)

ID Question Feeds
S1 Can SDF raymarching hold 60 FPS at our content budgets? doc 04, ADR-026
S2 Is slab-allocator throughput real (10⁢ writes/sec, p99 < 1 Β΅s, sub-ms Merkle pass)? doc 03. First numbers 2026-10-12, x86-64 Windows: P1.2 met (57.2 M appends/sec, p99 200 ns). P1.3 not met β€” 52 ms for one full 64 KB slab of scattered edits (68,524 digests at 759 ns, of which raw BLAKE3 is 508 ns) against 7 ms for the same count clustered: the pass follows digests computed, so 12N is a bound and not a cost, and OQ-49 asks which of target / budget / compaction frequency / scope moves. The O(NΒ²) resolution pre-pass (46 ms) is gone β€” resolution happens at the leaf by append, and no published digest changed
S3 Are BLAKE3 roots bit-identical across targets (N2600 / RTX 3060 / Linux-Mesa / M1-browser)? ADR-017/028
S4 FlatBuffers β†’ GPU zero-copy upload actually work? doc 09
S5 wasmtime + WIT determinism across Rust/TS/Python? doc 08
S6 Headless WGSL validation harness in CI doc 12, ADR-003
S7 Can macro causality propagate β€” dam in region A dries region B without loading B? doc 05, gate SM1 β€” the highest-risk spike
S8 Do the jump methods work β€” closed forms / matrix exponentiation over Ξ”t? ADR-005, OQ-09
S9 What does wgpu cost versus raw WebGPU on our pipeline? ADR-003, ADR-026, OQ-26
S10 What actually invalidates a compiled circuit graph β€” whole component, or dirty subgraph? (a real build, 10⁴ nodes, lever flipped, recompiled in measurable time, with the granularity reported) SPEC-07, OQ-22, doc 06 gates E1/E4

S7 is the one that can invalidate the architecture. Budget it first, not last. S1, S2 and S10 are the three that can invalidate a cost model, and every "instantaneous" claim in docs 03–06 is a cost model.

Phases

  • Phase 0 β€” Foundations: workspace, contracts/ from A-01…A-09, CI (cargo test, clippy -D warnings, fmt --check, cargo deny, the S6 headless shader check), coordinate property tests (gates C1/C2/C3) passing on the three architectures C2 names β€” x86-64, ARM64, WASM β€” which is a smaller set than the four targets of the determinism suite below, and the camera-relative f32 demo: no precision jitter at x = 10⁹. That demo is gate C4's run β€” camera-space error ≀ 1 f32 ulp at the declared render radius (doc 02 shows "< 1e-4" is unreachable at 4096, so radius and epsilon are declared together, not assumed) β€” plus a working hot-reload loop. Exit: the P0 pipeline works on a real change, and two agents could implement against the frozen contracts without talking.
  • Phase 1 β€” Engine runtime (domain-agnostic): ledger & slabs (P1.1–P1.6), renderer (R1–R5), macro sim (SM1–SM6, needs S7 βœ…), circuits (E1–E6, needs OQ-02 βœ…), physics/audio/animation (PH1–DE1), WASM host (W1–W5), asset compiler (AS1–AS5). Exit / pitch demo: a booting engine, a rendered procedural scene, an edit that persists, and one deterministic hash matching across all four determinism targets (gate P1.5).
  • Phase 1.5 β€” Multiplayer (deferred, ADR-004): ActionTx broadcast, prediction/rollback, epoch root exchange, bisection repair, one topology chosen (M1–M7). Not before every Phase 1 gate is green. Naming hazard: "Phase 1.5" (this multiplayer stage) and "gate P1.5" (the determinism gate in doc 03, run during Phase 1) collide by accident; neither id is renumbered, so prose should say "the P1.5 determinism gate" and "the multiplayer phase".
  • Phase 2 β€” The game ("Project Genesis"): content only, through the public API β€” the official game is Mod #1. Vertical slice (one biome, one mob, one tool, one craft, one weather effect) β†’ content expansion β†’ ecosystem & events β†’ low-end/browser optimization and prompt-to-mod packaging.

Phase 0 status β€” 2026-10-12

Measured locally (x86-64 Windows) and on CI runs 38124937828, 38150859744, 38159161144, 38179998839 and 38181630859 (x86-64 Linux, x86-64 Windows, ARM64 macOS), all at rustc 1.92.0. Six jobs green on each: three gate jobs, the wasm32-unknown-unknown build, cargo-deny, docs integrity. 38150859744 covers the ADR-029 sign-off, A-01's freeze and the licence; 38159161144 has A-03 and A-08; 38179998839 has A-10's contract; 38181630859 is the current main, with crates/ledger computing roots for the first time.

Evidence Result
cargo fmt --check, clippy --all-targets -D warnings clean
cargo test 4 passed (A-09 taxonomy) + 10 passed (A-03 delta record: P1.1 round trip, ADR-029's field order, the slot table's first-touch order and its 65,536 ceiling, the doc↔contract mirror) + 7 passed (A-08 ActionTx: wire tags, channel per kind, the A-03 seam, SequenceId's total order, the doc 10 mirror) + 8 passed (A-10 shape: node kinds, the eight octant orderings, the derived empty ladder, the list's odd item, the epoch root's field order, the doc 03 mirror) + 11 passed (C1–C4 gates, SPEC-01 masking, contract↔spec mirror) + 11 passed (crates/ledger/tests/root_vectors.rs: the 15 published digests, the 13 empty levels pairwise distinct, child order hand-built against octant 1 rather than 2 or 4, position-keyed roots, the payload mask, the slot-reassignment hazard, and node_parts agreeing with the contract's reference preimage) + 5 passed (crates/ledger/tests/commit_path.rs: the pipeline reproducing SLOT_TABLE/REGIONS/EVENTS_ONE/EPOCH_0 from raw records, last-write-wins and its order sensitivity, absent-row vs empty-row, short scratch refusing) + 7 passed (crates/ledger/tests/bisection.rs: gate P1.6's bisection walk) + 5 passed (crates/ledger/tests/read_path.rs: the read side reproducing the root the commit built, an untouched voxel reading absent rather than zero) + 7 passed (crates/ledger/tests/bisection.rs: gate P1.6's walk, the named voxel is the one that changed, an absent side reads None, ranked duplicates are order-independent and unranked ones are not) + 3 passed (contracts/tests/sdf_grammar.rs: artifact A-07's kind table and the 16-byte record round-trip) + 5 passed (crates/memory/tests/slab.rs: one store per append, refusal at capacity, O(1) reset, both candidate budgets as arithmetic, a 1 MB slab filled and recycled) = 118 passed, the same twenty non-empty suites on all three runners (run 38249012218, seven jobs green). The tests since the 104 note are crates/assetc/tests/asset_round_trip.rs (6: the reader walking bytes flatc packed, a writer-reader round trip, and seven untrustworthy buffers refused - short, wrong identifier, root past the end, a vtable that does not fit below its table, a vtable from a wider schema, a container declaring grammar version 2, and a header declaring a depth past MAX_TREE_DEPTH, both of the last two refused before a node is parsed), one more case in crates/engine/tests/boot_is_reproducible.rs (booting from an asset file on disk), and crates/render/tests/raster.rs (7: the CPU raster probe, coverage pinned against the analytic projection rather than a millisecond). SPEC-01's masked, never clamped rule was checked by injecting a clamp into DeltaRecord::new and watching the roll test fail with x: 4095 against x: 0; the vectors were checked by reversing the child iteration in octant_branch, which moved REGION_0 from 97627e32… to d748c899… and failed two tests β€” while child_order_is_x_low_bit_at_every_level stayed green, because it re-derives through the builder under test. Only a pinned literal catches a shape break
Gate C2 the six u64 lattice digests print bit-identical on x86-64 and ARM64 (0xa4659597a9d8aeca … 0x8e6da94e370cbc70); pinned in crates/coords/tests/gates.rs
A-10 root vectors cargo run --release --example root_vectors -p ledger prints the fifteen digests of doc 03 Β§ The published vectors, and all three runners print them byte-identically, EPOCH_0 91b97a52fc5137b50ec64550a94220daab0ffe05575bac6dbfda825a9947cc61 included β€” the first epoch root three machines have agreed on here. Not yet gate P1.5: spike S3 names four particular devices, and the browser half needs a WASM runner (OQ-41), not merely a compile
Gate C4 max |f32 βˆ’ exact| = 0 at radii 512 / 2048 / 4096 on every runner; casting globals first collapses 4032 of 4096 positions at 10⁹
no_std / WASM cargo build --target wasm32-unknown-unknown -p contracts -p coords -p ledger finishes: the contract guard still bites, and the hasher compiles for the browser target, which is what makes P1.5's M1-browser row possible rather than aspirational
cargo deny check all -s advisories 0 / bans 0 / licenses 0 errors, 6 warnings, 12 notes / sources 0. The graph is no longer empty: BLAKE3 arrived with crates/ledger plus the five crates its build needs (cc, cfg-if, arrayvec, constant_time_eq, cpufeatures), all inside deny.toml's allow list, so ADR-028's "first external dependency" is now cargo-deny's first real audit. The 6 warnings are still-unused allowances; unused-allowed-license flipped from allow to warn in that same commit, as the file instructed
Doc integrity 18 files, 341 local links, 61 OQ + 9 SPEC + 31 ADR + 10 artifacts + 10 spikes β†’ no findings. Getting here meant fixing the checker: it opened a quoted region on every apostrophe, so the lifetime ticks in A-03's mirrored block made it swallow real braces and report a phantom imbalance on a block that is character-for-character its contract

In the tree: A-01 contracts/coords.rs β€” frozen 2026-10-11; its DRAFT header came off when ADR-029 was signed off and locked SPEC-09. A-10 contracts/tree.rs β€” drafted 2026-10-11, frozen 2026-10-12 on ADR-030, and the first artifact frozen against computed digests rather than against prose. A-03 contracts/delta.rs and A-08 contracts/tx.rs β€” drafted 2026-10-11, both DRAFT: ADR-029 gave them a byte layout to be written against, so they are the record and the envelope, mirrored verbatim into docs 03 and 10 and pinned against drift by delta_contract_matches_spec_document and tx_contract_matches_spec_document. Neither freezes while OQ-45 and OQ-46 are open: A-03's payload field is opaque and A-08's identity widths are labelled drafts on purpose. A-09 contracts/errors.rs β€” still DRAFT: it waits on the A-02/A-05/A-07 payloads and on A-08 freezing (A-08 now exists, so what it owes A-09 is a decision, not a file), and now that A-01 is frozen, adopting A-01's newtypes for its coordinate payloads is a scheduled migration rather than a blocked one. And crates/ledger β€” the workspace's first crate with behaviour: node_parts is the only place a preimage is built, EmptyLadder derives the thirteen absent-subtree digests, and it is no_std on purpose so the same code can root an epoch in a browser.

Still unmet (rest of the Phase 0 exit): the S6 headless shader check has no CI job (no WGSL exists to compile, OQ-41); the dev-profile loop is now two commands that work β€” assetc shape.ron shape.bin then engine shape.bin β€” and one that does not, a watch mode that reruns the first and hot-swaps the second; WASM is compiled but never executed, so C2 covers x86-64 + ARM64 execution and WASM compilation only; A-04/A-05/A-06 have no file at all and of the seven that do only A-01 and A-10 are frozen β€” A-02 (ledger-api.wit) and A-07 (sdf.rs + sdf.fbs) are written and machine-checked but stay DRAFT, as do A-03, A-08 and A-09. The commit path landed the same day (crates/memory appends records into slabs; crates/ledger::commit resolves last-write-wins and roots them, and its test reproduces EPOCH_0 from raw DeltaRecords), and spike S2 measured it: P1.2 met at 57.2 M appends/sec with p99 200 ns; P1.3 not met at 4.9 ms for the pass over one full 64 KB slab region and 93 ms for the whole commit, because the dedupe is still O(nΒ²) and the 12N model itself puts the hashing floor near 5 ms. The first implementation's O(NΒ²) walk-order insertion sort was replaced by an in-place octant partition, the pass became linear (10Β³ β†’ 907 Β΅s, 10⁴ β†’ 5.8 ms, 10⁡ β†’ 52 ms), and no digest changed β€” the position-keyed claim doing work in measurements instead of prose. Whether the gate's target, the slab budget or the gate's scope moves is OQ-49; OQ-47's frame-0 visibility still has no code, because nothing projects a delta into M_active yet.

49 validation gates are defined across the subsystem docs, in families C (02), P1 (03), R (04), SM (05), E (06), PH/AU/AN/DE (07), W (08), AS (09) and M (10); a milestone is met when its gates pass.

The Bitwise Determinism Suite

The name AGENTS.md Β§4 and ADR-017 cite as the acceptance test for anything another machine must see. It is not a new test framework β€” it groups gates that already exist, and this table adds no new gate:

Part What it proves Where it runs
C2 identical hashes for the same (seed, pos) on x86-64 / ARM64 / WASM doc 02, Phase 0
C3 no precision jitter at x = 10⁹ (Far Lands regression) doc 02, Phase 0
C4 camera-space error ≀ 1 f32 ulp at the declared render radius doc 02, Phase 0 demo
P1.5 byte-identical epoch roots for one seed + transaction sequence on N2600 / RTX 3060 / Linux-Mesa / M1-browser doc 03, produced by spike S3
W1–W3 Rust / TypeScript / Python mods over the same WIT surface agree bit-for-bit doc 08, produced by spike S5

Explicitly not in the suite: gate R4 (frame hash across wgpu backends) is informational β€” a renderer output is not state, and promoting it to a gate would silently re-adopt the GPU-authoritative model ADR-017 rejects (doc 04). A frame hash is worth pasting as a diagnostic, never as proof of correctness.

A feature that produces a value other machines must see is not done until its row here passes.

Timeline honesty

Every number the source conversation produced is an unvalidated LLM estimate, kept only because a plan needs a shape. Proof of concept "1 month", playable core "3–6 months / 1.5–2 accelerated", Minecraft-level diversity "9–15 months / 4–5 accelerated".

The accelerations (reuse an existing runtime, WGSL headless auto-testing, agent orchestration discipline, noise-tree content bootstrapping, single-player-first) are risk removals, not time savings β€” removing a risk you were about to hit is not the same as finding weeks you didn't spend.

Planning position: commit to the gates, not the dates. Nobody declares a milestone met by elapsed time; the first honest schedule comes out of P0/P1 gate data. Expect the accelerated numbers to be optimistic by a lot β€” three subsystems are novel (macro causality, stateful compiled-graph circuits, SDF frame budget), each pillar has one unproven claim, and agent-written GPU code is a debugging situation none of the estimates priced in.

What we will NOT do

  • Start content work before the lighting/shading contract exists (OQ-10).
  • Add multiplayer before Phase 1 is green.
  • Re-open locked ADRs in code β€” re-open them in the decision log first.
  • Let a benchmark target be "whatever the current code happens to hit".

Gate P1.6 has its walk

crates/ledger::repair::first_divergence (landed 2026-10-12) descends a region root comparing both sides' child digests octant by octant until it reaches the one voxel that differs, and reports (x, y, z) plus each side's payload β€” None on a side that holds no record, because absence is the ordinary desync and a 0 would read as agreement with unedited terrain. Detection is ADR-023's root exchange; choosing a winner is OQ-46, deferred by ADR-004, so a hit today means a replay was not deterministic rather than asking for a merge. Tests also pin the sharper lesson: two records for one voxel resolve by append, so ranked duplicates are order-independent and unranked ones are not β€” a caller bug, measured instead of asserted.

Artifact A-07 and gate P1.7

A-07 (contracts/sdf.rs, DRAFT, ADR-031 proposed) is the shape/material node grammar ADR-012 said must be a public surface: 15 kinds with stable numbering, arity fixed by kind table rather than by the file, a 16-byte four-word node record read from one WGSL storage buffer, pre-order with subtree_len so a sibling is arithmetic. Its drafting lost bits on write in the first layout (flags ate the top byte of a parameter count) - a test caught it, review did not - and that case is now pinned.

Gate P1.7 was named in doc 03 for the half of OQ-47 that is testable today: a delta committed into epoch e is what the ledger reads back for that voxel, verified by rebuilding the read side and hashing it to the root the commit produced. The visibility half - an edit reaching M_active and drawn on the frame it was made - stays open with the renderer.

What is still not done, plainly, as of this section (the later sections below move two of these): there is no renderer β€” a CPU raster now, still no GPU β€” no macro simulation, no compaction, and gate P1.5 had agreed on one machine rather than its four; it reports three architectures today, and still none of the named devices. Spike S2 measured P1.2 met (57.2 M appends/sec, p99 200 ns) and P1.3 not met (52 ms per full scattered slab against a sub-ms target, ~35 ms of it raw BLAKE3), and which of target / budget / compaction frequency / scope moves is OQ-49, a decision rather than a measurement. Gate P1.4 had no code at all then; it has a directory, tests and a measured cost now, and the section at the end of this page says what that cost implies.

crates/render: the grammar, evaluated on the CPU

Landed 2026-10-12 (9f68a4a, CI 38230129783 green on all six jobs). validate, sdf_eval, march and normal over the eleven kinds the evaluator implements, refusing Twist/Bend/Noise rather than guessing a distance for them. Two of doc 04's claims became measurements:

  • The step guarantee β€” a ray from six units out hits the unit sphere at t = 5 in at most two steps, the hit lies on the surface, and a ray aimed away ends on the step budget.
  • R5, as far as it can be tested without a GPU β€” a scene and its millionth-scale copy, marched with proportionally scaled epsilons, need nearly the same steps and agree on the hit to under 1% relative. The attempt also found its own bound: the f32 ulp near 1e-6 is about 1e-12, so an absolute epsilon finer than the representation can never be satisfied. That is what docs/02's camera-relative coordinates are for, not a knob to widen until the test goes green.

R1 (1080p/60, 500 culled entities) and R2 (ten minutes of continuous editing) stay unmeasured: CPU steps per ray are not GPU nanoseconds per frame, and there is still no WGSL interpreter, no BVH and no assetc. The renderer workstream has a reference to agree with now; it does not yet have a renderer.

A-07 is whole: grammar and envelope

contracts/sdf.rs (kinds, fixed arity, the 16-byte record, pre-order with subtree_len) and now contracts/sdf.fbs (2bd506a), the compiled envelope that carries a tree: ShapeAsset with grammar_version, depth, a vector of fixed-layout NodeRecord structs and the f32 pool, root_type, identifier MLWS. Nodes are FlatBuffers structs rather than tables precisely so the asset bytes are the GPU buffer - no repacking, no vtable on a per-pixel path.

Checked with the real compiler: flatc 24.3.25 --rust emits pub struct NodeRecord(pub [u8; 16]), so the stride promise comes from a code generator rather than a comment, and a probe asset packed to 156 bytes. flatc is not a CI step yet; what CI enforces is that the two files still describe one layout (contracts/tests/sdf_schema.rs).

What this does not unlock: assetc still does not exist, so nothing emits the format - AS1-AS5 stay open, and the distance from an authored tree to a drawn scene is still the WGSL interpreter, spike S6 and the export-lint decision (task #14) that gates it.

The engine boots, and the gate table says what is met

cargo run -p engine (2026-10-12, 7ff3200/9f68a4a era) is the workspace's first binary. One process: three edits into a 64 KB slab, committed into an epoch root, read back through the ledger's read path, a four-node scene marched, and the slab reset in O(1). It asserts docs/03's published literals as it goes - EPOCH_0 91b97a52…, the region list, the slot table, the region root - which is why CI runs it on all three architectures as a check rather than a smoke test: a break anywhere in slab to commit to root to read surfaces as a mismatched digest.

crates/assetc stage one lowers an authored Shape into those same A-07 records, deriving subtree_len instead of asserting it and refusing a bad axis, a NaN parameter, and a tree past the draft depth cap. It writes no bytes and reads no files yet, so AS1-AS5 stay open.

docs/11 gained a gate-by-gate Phase 1 exit table with the evidence cited per row. Its summary, kept deliberately unflattering: one of the demo's four clauses is true, one is true at the ledger level, two are not started - there is a booting engine and a persistent edit, and there is no drawn scene and no four-device agreement.

The export ban is now an architectural question, not a style rule

unsafe_code = "forbid" rejects a hand-written #[no_mangle], which is AGENTS.md Β§5 working as intended. Building the wit-bindgen path anyway showed the generated code is rejected too - export_name, an unsafe fn, and an unsafe block all arrive from the macro. So gate P1.5's browser target and spike S6 wait on a policy decision (a scoped lint exception, an out-of-workspace ABI crate, or host-owned exports), not on missing effort. Three decisions now sit with the human: that one, OQ-49 (what gate P1.3 means now it is measured at 52 ms against a sub-ms target), and ADR-031's sign-off freezing A-07.

The boot is a check, and the transforms landed

Two follow-ups from 2026-10-12, both CI-verified (38236434430, 38237235123):

  • crates/engine/tests/boot_is_reproducible.rs. The engine's output is evidence, so it is now enforced rather than observed: two spawns produce byte-identical stdout, the digests it prints are the ones doc 03 publishes, and an all-zero digest is refused outright. A ledger change that silently moves a root now fails from outside the crates that produce it, even if every in-crate test is updated to match the new number - which is the point of checking published values rather than internal ones.
  • crates/render evaluates thirteen of A-07's fifteen kinds, adding Twist and Bend. Noise stays refused: it wants the integer lattice hash and OQ-45's payload meaning. Two details are the substance rather than the code - perpendicular(axis)'s ordering is contract (a WGSL twin that picked the other pair would evaluate a different linear map and disagree node-deep), and Bend is documented as a shear with a chosen sign because the grammar fixes none. One test is the guard for the first: a sphere centred on the twist axis cannot tell how far it turned, and swapping the pair stops that being true.

Honest about the two things these do not do: the reproducibility test can only spawn its own build profile, so the debug-versus-release equality stays a hand-measured note on gate P1.5's row; and an evaluated scene with thirteen kinds is still not a drawn one - no WGSL, no BVH, no culling, so R1 and R2 remain unmeasured and gate P1.5 still reports three architectures rather than its four devices.

One axis of the fourth device was reachable from CI, and is now checked: blake3 picks its implementation at runtime from CPU features, and one of P1.5's four targets is a Celeron N2600 β€” an SSE2-era chip that will never execute the AVX2 path. CI therefore runs the published A-10 root vectors again under blake3's pure (scalar) build, on all three architectures: the fifteen digests are unchanged, so an ISA-dependent hash is no longer a failure class S3 has to discover on the machine. Stated precisely, because it is a proxy and not the device: this removes "we hope SIMD and portable agree" from the list of things the N2600 run could still reveal; it does not run on the N2600, and the gate stays at three architectures until that hardware reports.

The asset pipeline writes a file now

crates/assetc lowers an authored Shape into A-07's records (earlier note) and now serialises them: writer::to_bytes produces a ShapeAsset, and CI hands those bytes to the pinned flatc, whose own parser reports depth 1, the node's packed words, four parameters and the label (83d0d70, run 38239484945). The verdict on the format therefore comes from code that did not write it.

How it got there is the instructive part. The first draft flatc refused, which exposed three rules no local reader would have taught: the vtable sits below the table (soffset subtracted, not added), a field equal to its schema default is omitted rather than written, and the MLWS identifier follows the root offset. The second draft made the reference parser segfault - a vtable advertising a field the table never contained - which is why tests/written_asset.rs now verifies the arithmetic a reader resolves (root to table to vtable, advertised slots inside the declared object size, vector lengths against the buffer, the string's NUL terminator) instead of trusting a reader written by the same hand. That test then caught a third bug: an object size that left out the leading soffset. The last fix was a one-field off-by-one, a table's uoffset naming the vector header rather than its elements.

Still not claimed: nothing reads a file from disk. The authoring format is doc 09's RON story and the content schema is OQ-10, so AS1 and AS3-AS5 stay open. And the writer stays hand-built on purpose - the flatbuffers crate's vector-of-struct support needs unsafe traits, which the workspace forbids, so generated code would require the same scoped-allow decision as task 14's bindings.

assetc is a command, and one licence allowance was made in the open

assetc <in.ron> <out.bin> now compiles an authored shape file into the compiled blob the runtime will load (4260879, green on 1449750 / run 38241246999, all 7 jobs). RON parsing lives only in this tool - AGENTS.md section 3 bans string parsing on the frame path and exempts tools - so the "assets are pre-compiled, nothing parses at runtime" claim finally has a place where it is true. CI compiles the fixture through the CLI and feeds the result to flatc, whose parser reads it back: the format is vouched for outside the repo that writes it.

A decision worth recording rather than burying. cargo-deny rejected unicode-ident 1.0.27, whose licence is (MIT OR Apache-2.0) AND Unicode-3.0 - under an AND, every conjunct has to be on the allow list, which carried Unicode-DFS-2016 and not Unicode-3.0. Pinning was not available: wit-parser (which gives A-02 its parse check) and serde_derive (which gives assetc its RON input) both require the newer version. The options were widen the allow list, drop the RON path, or drop the WIT parse check; the list was widened, on the reasoning that a character-database licence is not the copyleft category the deny list exists to catch, flagged in deny.toml against OQ-43 as needing ratification, with a one-line revert if that is not the agreed call. This is licensing policy, so it is the user's decision - the agent's part was to make it visible instead of either leaving trunk red or changing policy quietly.

One CI lesson from the same commit, because it generalises: the flatc step failed on a stale label assertion of mine while the file itself read back perfectly. When an assertion and an artefact disagree, check which one is lying before believing the failure.

The frame cost is steps x nodes, and a missing convention blocks "an edit you can see"

raster_probe now grows only the tree on a fixed 96x72 frame, which answers the question a content budget has to ask: what does one more node cost? Almost nothing to the march itself - 659,801 steps for one node against 627,890 for 31, a 5% move - and linearly to the work inside each step, about 1.5 ns per extra node per step on this machine. So the budget law is steps x nodes, the two bindable levers are the step budget and the node count, and MAX_TREE_DEPTH is load-bearing rather than tidy. The nanoseconds are the noisy half of that table: two runs of one binary on one laptop put the one-node grid at 1.9 ms and 3.5 ms while the step counts repeated exactly. Then the sweep went into CI, and the two halves separated cleanly: run 38254332031 prints 659,801 steps for the one-node tree and 627,890 for the 31-node one on x86-64 Linux, x86-64 Windows and ARM64 macOS identically - digit for digit on three machines, because a march step belongs to the field along the ray rather than to the tree or the hardware - while the timing half varied as timings do, ns per node-step falling to 0.43-0.78 at 31 nodes against 1.58-1.62 at one, depending the runner. Hence doc 04's rule rather than preference: bind node-steps, not milliseconds. A budget in nanoseconds would be a claim about one machine, in a project that promises several.

Trying to take the next step - making a persisted edit show up in the picture - found a gap instead of a task. Projecting an edited voxel into an A-07 cube needs to know which world span GlobalVoxelPos names: the unit cube [x, x+1), or a cube centred on the integer. Doc 02 says a voxel is 1 unit and gate C1 pins region*4096 + offset == pos; neither says where the integer sits inside its own cube, and no other doc does. Nothing hashed depends on the answer, so it is not a determinism question - but the raster path, doc 07's collision and the eventual base terrain all have to agree, which is exactly what ADR-021's single coordinate authority exists to prevent. Inventing it in whichever subsystem asks first is the escalation case, so it is now OQ-50, flagged in doc 02's own unit table, and gate P1.7's row in the exit table names it as the reason the frame-visibility half has no code.

Spike S2, part three: where the sub-millisecond line actually falls

OQ-49 lists four things that could move for gate P1.3 - target, slab budget, compaction frequency, scope

  • and two of them were assertions rather than measurements. cargo run --release --example pass_budget -p ledger measures them (digests counted by region_root_counted, release profile, x86-64 Windows):
Edits in one region Clustered digests / time Scattered digests / time
1 13 / 12 us 13 / 10 us
8 24 / 14 us 91 / 63 us
32 70 / 35 us 354 / 249 us
128 260 / 124 us 1,321 / 895 us
512 1,026 / 496 us 4,938 / 3.383 ms
2,048 4,096 / 1.985 ms 18,489 / 12.861 ms
8,192 (a full 64 KB slab) 12,287 / 5.023 ms 68,524 / 48.589 ms

The 1 ms line sits between 128 and 512 edits per region per epoch, and the arrangement decides which side of it you are on. Per digest the cost holds at roughly 408-938 ns, consistent with BLAKE3 being the floor rather than this code.

Two findings contradict what the docs had been asserting. Spreading the same 65,536 edits over eight regions computes 548,192 digests against 483,285 kept in one region - 13.4% more work, because separate trees cannot share interior nodes - so "batch the hash pass across regions" is a parallelism idea, not a work-reduction one. And committing 512 scattered edits with 1, 2, 8 or 16 slot-table rows costs 4.10-4.43 ms in every case, so the number of live regions per epoch is not the cost driver, against the instinct that more regions means more overhead.

Which lever moves is still the owner's call - see OQ-49. What changed is that the choice can now be made against numbers, and that CI prints the sweep on every runner as information: run 38251942742 repeats both digest totals exactly on x86-64 Linux, x86-64 Windows and ARM64 macOS, while its timings disagree about which arrangement is faster (spread measured 15.0% and 14.5% slower on the two Linux-class runners and 10.1% faster on ARM64 macOS). Counts are portable; a shared runner cannot rank two workloads 10% apart. That is also why the full-slab scattered count - 68,524 digests - matters: a second, independently written example reproduces the published figure exactly, after this file's first draft had a too-regular position generator reporting 12,287 digests for the same edit count, i.e. a faster pass over a different arrangement.

The runtime reads the file: a reader, an oracle fixture, and an engine that boots from disk

Three commits, one direction reversed. assetc::reader::from_bytes (b4d4128) was written against the opposite standard from the writer: it had to parse bytes this repository did not produce. crates/assetc/tests/fixtures/probe_shape.flatc.bin is the pinned flatc 24.3.25's own output for the same JSON the CI job packs, committed because a test cannot run a compiler it does not have. That makes it the one place AGENTS.md section 8's ban on compiled .bin files is knowingly excepted, and the exception is the point - an oracle fixture is not a build artifact, and a reader validated only against its own writer can share a mistake while one fed a foreign implementation's bytes cannot.

The fixture earned its keep before it was committed: the first draft read the MLWS identifier as the table's soffset, because a uoffset resolves relative to its own position and the root offset sits at byte 0. Field presence is read from the vtable rather than by position for the same reason a writer omits defaults - grammar_version equals its schema default in this asset and is therefore absent from the table entirely, so positional reading would hand back whatever bytes happened to follow. Five ways to corrupt a buffer are refused rather than guessed: too short, wrong identifier, a root offset past the end, a vtable that does not fit below its table, and a vtable advertising more slots than this schema has. The last refusal needed a hand-built buffer, since our writer places its vtable immediately before its table and can never reach six slots without tripping the bounds check first - a test that would have silently exercised a different branch than it claimed.

Then the seam, in two steps. crates/assetc/tests/cli.rs (b4d4128) stopped asserting arithmetic about the CLI's output and started loading it: RON file in, .bin on disk, reader::from_bytes, render::validate, and a ray that marches the loaded unit sphere to t = 3.0. engine <asset.bin> (19ff900) made the same path the boot rather than the test: read the file, load it, validate it, march it. Pointed at the 97-byte asset the compiler wrote from sphere.ron, the boot prints 97 bytes read from target/sphere.bin (1 nodes, 4 parameters, depth 1, grammar version 1, stamped "sphere.ron") and marches to t = 3.1964 - the surface computed outside the code under test. With no argument it still compiles its own five-node scene into a 204-byte asset and loads those bytes, so the default demo stays self-contained, and debug and release print the same twelve lines. (That count is the era, not the present: the raster grid made it thirteen and gate P1.4's residency round trip made it fourteen, both described further down this page, and the equality between profiles has held at every step.)

Two habits worth naming, because both caught something the other check missed. The disk-boot assertion was falsified before it was trusted: writing r = 2.0 into the asset moved the boot's answer to 2.1964 and the test failed with the expected meeting point in its message. And cargo clippy --all-targets -D warnings failed where cargo test passed, on skip(1).next() - the gate list in AGENTS.md exists because the checks see different things, not because they are redundant.

Cross-architecture evidence arrived with it, free: runs 38243292729, 38243836093 and 38244520595 are green on all seven jobs, and the last prints the same nineteen non-empty suite totals - 109 - on x86-64 Linux, x86-64 Windows and ARM64 macOS. That is the first time one asset buffer, packed by flatc on Linux, has been shown to parse into identical nodes, pool and label on three machines.

Two promises the loader was not keeping, found by reading doc 09 and the schema against the code they describe. grammar_version was carried, written and parsed, but never compared - the one thing the field exists for; and contracts/sdf.fbs states that depth is carried "so the loader can refuse before it walks anything", which nothing did. 40dab12 and 33fee95 make both real: from_bytes compares the version and the declared depth against MAX_TREE_DEPTH before reading a single node, because the version decides what every later byte means and the depth decides whether the marcher can budget the tree at all. The version comparison is exact equality, not a window: the accepted range and migration strategy are OQ-30's open question, and A-09's SchemaVersionUnsupported is where a policy belongs, so the loader states the boundary rather than inventing one. And the depth check is explicitly not gate AS5 - that gate is assetc rejecting an over-budget tree at compile time, which needs a per-tree budget the docs do not define yet (doc 04's five frame numbers are provisional, and none of them is per-asset).

A harness note in the same spirit: run 38245836230 reddened trunk on one job, cargo-deny, and only because the pinned binary's download returned 504 from a CDN edge - the other six were green and the commit had nothing to do with dependencies. Both pinned downloads now retry three times (67019d2), which retires the transient while leaving the gate intact: an unfetchable pin still fails the step, no continue-on-error anywhere, and the sha256 check on cargo-deny still runs before it is used. A red trunk that is really a network event is how a later genuine finding gets dismissed as another flake.

Still open, and now honestly listed as open rather than as missing: a GPU upload of the loaded buffer, a watch or hot-reload mode, a versioned authoring schema (OQ-10), and compile-time rejection of an over-budget SDF, so AS1-AS5 stay open; A-02's binding is still the blocked step behind gate P1.5's browser row. And the demo's second clause keeps its narrow wording - the scene is evaluated and marched on a CPU, not drawn: no WGSL, no BVH, no culling, so R1 and R2 remain unmeasured.

Spike S7 runs: latency is distance over ring, and cost is a curve, not a cliff

docs/05 proposes the three-stage resolution model, then concedes it has not validated the hard part: how does region B know its neighbour's water table without loading it? β€” with the named failure that the ring may be too small (effects arrive late) or too large (commit cost explodes). Both halves are numbers, so cargo run --release --example s7_propagation -p engine made a 512-region river of i32 water tables, dammed the source, and advanced a frontier that simulates only the disturbed width each commit.

ring commits until the mouth (500 regions away) dries region-ticks, as % of simulating the whole river
1 501 57%
2 251 79%
4 126 89%
8 64 95%

Three conclusions, one of them against the document:

  • the without loading it half holds. Nothing beyond the frontier is touched, so a commit's cost is bounded by the disturbed area, not by the world and not by the single edit that caused it;
  • the within one epoch half does not, and that is the design rather than a defect. Latency is distance over ring β€” the dam β†’ riverbed β†’ wells chain this page describes, now with numbers attached;
  • cost is a curve, not the warned-about cliff. A wider ring spends 57% β†’ 95% of whole-river work: it buys latency by pulling the same work forward. OQ-01 should be argued against that curve rather than against the warning.

Two mistakes were caught by running the model rather than reading it: the first field used proportional evaporation and left the lower river dry at equilibrium, so four of five distances reported never β€” a region already dry cannot be seen to dry; and the commentary drafted before the table printed claimed the work barely changes, which the 1.65Γ— column contradicted. The printed numbers replaced the sentence.

What the spike cannot reach is SM1's other half, identically for two clients: that needs a hashed macro field, and A-04's schema and A-05's tag registry do not exist. So S7 stays open, narrowed from "can causality propagate at all" to "how much multi-epoch lag will we accept" β€” with the ring as the dial and the trade-off measured.

The roots run inside a WASM engine, and the blocker we cited for it was a misreading

docs/11 said, from the export-lint discovery until today: "WASM is compiled but never executed… the runner has to be wit-bindgen output, which needs A-02." Each half of that is true on its own β€” unsafe_code = "forbid" rejects a hand-written #[no_mangle], and it rejects what wit-bindgen generates β€” and the conclusion did not follow. A WASI command exports _start because the Rust runtime provides it, so the ban is never reached.

The engine now builds for wasm32-wasip1 (a 191 KB release module), runs in CI under Node's WASI via tools/wasm_parity.mjs, and its output is diffed against the native build's: 13 lines identical, EPOCH_0 91b97a52fc5137b5…, the region list and the slot table among them β€” checked in debug and release before merging, green on main in run 38265695965. It is a matrix job across all three runners now, and 38270620391 logs parity ok on arm64 from the macOS runner and parity ok on x86_64 from Linux and Windows β€” so the ARM in the gate's M1-browser row is covered by V8 on Apple Silicon: the instruction set, not the browser. The step refuses to be vacuous: an empty WASM output fails the job, because an empty diff between two empty files is a green check that means nothing.

What that buys gate P1.5 is a fourth kind of target β€” different code generation, a different target's runtime, a different machine model for function calls, agreeing on hashed state. What it does not buy is spike S3's fourth device: M1-browser means a browser on an M1, and this is V8's WebAssembly on the runner. Nor are the marched f32 and raster counts in those same lines evidence of anything β€” they agree only because the march is add, multiply and sqrt, they are presentation, and ADR-017 keeps frame hashes informational by policy. OQ-41's export-lint decision stays open and stays relevant for mods exporting functions a host calls; it was simply never what stood between this repository and running its own roots in a WASM engine.

The field becomes a picture, on a CPU, with a measured cost shape

Everything in crates/render answered one ray at a time, while docs/04's budget table - naive, perfect culling, realistic BVH - was three multiplications of guesses beside it. render::raster (237c916) marches a pinhole grid over the same march() and shades the surface normal, so the renderer workstream now has a measured shape to beat rather than only a target to hope against. The pinhole takes an image-plane half-height instead of a field angle, which keeps trigonometry out of ray generation entirely - and keeps every number the tests recompute recomputable.

Measured on one machine, one 3-node tree (a ball on a ground plane), release profile, 96-step budget, surface = 1e-3:

Grid Rays Evaluations Per pixel Hit fraction
96x72 6,912 314,554 45.5 67.0%
192x144 27,648 1,258,840 45.5 67.0%
320x240 76,800 3,495,846 45.5 67.0%

Per-pixel cost is flat with resolution. Hits average 20.6 steps, and the 33.0% of rays that find nothing are charged 69.6% of all evaluations - which is ADR-024's whole argument expressed as a share instead of a derivation, and the number the culling work has to move.

Why the counts and not the milliseconds: they repeated exactly, the times did not (the same 76,800-ray grid measured 53 ms and 86 ms in one session on one binary). So tests/raster.rs pins coverage against geometry an outside calculation can predict - a unit sphere at six units subtends tan(asin(r/d)), which is about 368 of 4,096 pixels, asserted within 8% - alongside depth, absence at the corner, the fact that doubling a radius changes coverage and depth together, and that two renders of one tree are one image byte for byte. The first scene helper in that file failed honestly at authoring time: it stamped FLAG_IS_ROOT on a child as well as the root, validate answered RootCount(2), and three tests stopped instead of quietly rendering a picture two implementations would disagree about.

The boot marches a grid of its own (a2465dd, step 6): 3,072 rays through the same loaded asset, reported as integers - 590 find surface, 243,008 evaluations charged, of which 238,272 (98.1%) belong to the rays that found nothing. Integers on purpose: the march and the normal use only add, multiply and sqrt, all exact under IEEE-754, so run 38250242415 prints that line byte-identically on x86-64 Linux, x86-64 Windows and ARM64 macOS - the first whole-pixel-grid comparison this repository has made between machines. It is recorded as a diagnostic in both docs, beside the number rather than after it, because gate P1.5 is a claim about hashed state and none of this is hashed (ADR-017, gate R4).

What it does not claim, printed in the example's own header and in the module's: this is not R1. No GPU, no WGSL interpreter, no BVH, no culling, no window, and a normal-mapped colour that is deliberately not a material model, because material_eval's lighting half is OQ-10's open question and a pretty picture is not a decision. Gate R1 and R2 stay unmeasured; what changed is that "renders a procedural scene" now has pixels behind it on the CPU, and the cost story has its first measured line.

The first culling structure is an enclosure, and it disproved the 3x estimate it replaced

render::cull derives one conservative sphere per subtree by set relation - union covers both children, a smooth union grows that cover by k because smin rounds outward by at most k, subtract and intersect take their first operand's bound because the result is a subset of it, mirror covers the child and its reflection - and render_culled puts one segment test in front of every ray. Plane, Repeat, Twist, Bend and Noise answer Unbounded, which means no early-out rather than a guessed box, because a bound that is too small deletes geometry and the result looks exactly like a correct render. That refusal is why the module is testable at all: the property asserted is image equality, not speed.

cargo run --release --example cull_probe -p render (now a CI step, since it prints only integers) costs a 64x48 grid at 96 steps per ray:

Arrangement Spheres Bound r Steps plain Steps after the ray cull Steps saved
cluster 1 0.40 294,544 16 100.0%
cluster 8 2.20 291,594 14,250 95.1%
cluster 128 16.09 278,136 147,384 47.0%
line 8 4.60 291,164 80,540 72.3%
line 32 19.00 287,112 287,112 0.0%
line 128 76.60 287,112 287,112 0.0%

Spike S1's reading in doc 04 guessed an early-out was worth up to about 3x. The measurement says the variable was wrong: the same structure saves everything on a compact scene and nothing on a spread one, at the same 88 hits, because a single bound grows until it contains the frustum and every ray is merely permitted. That is ADR-024's hierarchy converted from a promise into a requirement with a baseline - the Steps saved column is a floor a hierarchy has to beat, and the node-visit table further down is where it does - and it arrived by building the weak version first rather than by arguing about it.

Ten tests in crates/render/tests/cull.rs carry correctness, all of it agreement: pixels, depth and hit count identical to the unculled run with the saving exactly skipped * max_steps; interior points sampled from the evaluator and required to lie inside the claimed sphere, which makes the field the oracle instead of this module's arithmetic; and unbounded scenes skipping precisely zero rays while costing what they used to. They found no bug on the first pass, which doc 04 says plainly - their value is the next bound rule, not this one. R1 and R2 are untouched: these are counts of work avoided, never nanoseconds.

The per-node skips followed in the same unit, and the table above is why they read as a nothingburger. scene_bounds builds one enclosure per node in a single reverse pre-order pass (children sit after their parent, so no recursion and no re-walk), and sdf_eval_bnb consults it inside the existing eval_at β€” one evaluator with an optional table, not a second implementation free to drift, which is the same argument the WGSL agreement suite exists for. A union's arm declines to evaluate its second child when that child's own bound is already farther than the value in hand.

Two findings came out of that, and both are the useful kind. First, the precondition for a skip is not "the bound contains the surface" but "the field is at least the distance to its own bound" β€” a min preserves that, a max inherits it from the first operand (so Subtract and Intersect can be pruned below), a mirror keeps it, and smin breaks it, returning less than min by up to k/4, so no node under a smooth union may be skipped however far away its sphere is. That bit is computed structurally in the same pass and pinned by a test asserting the refusal.

Second: the probe's first run reported the per-node layer as saving exactly nothing, and that was the instrument's fault rather than the structure's. Stats::evaluations counts march steps, and a skip inside a step changes no step count β€” the pruned path returns the identical distance, so the trajectory is bit-for-bit the same by construction (which is also the proof that skipping is safe). So SceneBounds now tallies nodes entered, render_culled counts without skipping and render_bnb skips: the same frame, the same steps, measured twice. render reports 0 in that field, because it has no table and will not print a number it did not measure.

With the right unit the numbers appear. Where the single scene bound is worth 0.0% β€” 32 and 128 spheres laid along a line, one bound swallowing the frustum, 88 hits either way β€” the per-node layer avoids 38.2% and 46.3% of node visits; on the 128-sphere cluster it avoids 77.3%, and the share grows with the count instead of decaying. The two layers are complementary rather than one replacing the other: on a compact scene the ray test still does most of the work, and on a spread one it does none while the per-node bounds do nearly half. ADR-024's O(log N) skip now has a measured floor to be priced against, and it is the Nodes unpruned column above.

A third arrangement turned the same instrument on the build itself, and it is the most actionable number in this section. The probe now scatters spheres with the same integer hash spike S2 uses and emits that scene twice - once in the order the centres were generated, once after a median split on the widest axis. Same spheres, same camera, same pixels, same steps, same unpruned visits: only the tree shape differs. Authored order lets the per-node bounds avoid 15.2% of visits at 32 spheres and 20.5% at 128; the median split avoids 46.1% and 54.3%. That is 2.6x to 3.0x more work removed by an emission decision, and the root bound tightens with it (r 138.0 to 84.7) because covering two spheres by midpoint-and-max is order-dependent too - a tree shape is looseness as well as traversal.

It was made in b1cf3bd: assetc::order::rebalance flattens a plain-union forest into a set, sorts it along the widest axis by each item's extent and rebuilds it balanced, and lower runs it before a node is emitted - so eight spheres authored as a chain reach the marcher as four levels over the same 15 nodes, and the compiler carries the gain instead of waiting for an author to know it. The equivalence is the test rather than a comment: a chain and a balanced tree of one set of spheres lower to identical nodes, params and depth, and the lowered field equals the minimum of those spheres computed inside the test at 288 sample points - a check against arithmetic outside the code under test, not the implementation agreeing with itself. The engine boot printed the same asset bytes, the same t = 3.0070 and the same 243,008 evaluations afterwards, which is the same claim observed from the other end.

So the scope matters: reordering children is safe exactly under a plain union, where min is commutative and associative with no rounding question, and unsafe under subtract, whose operand order is its meaning

  • a smooth union's children cannot move past its blend either, and a plane or repeat has no finite extent to sort on, so those forests come back unchanged and a test asserts each case rather than trusting the sort to behave.

Still no gate moved: R1 and R2 are frame-time claims with no GPU, and no column here may be quoted as nanoseconds β€” work avoided has never been calibrated to time in this repository.

The cull gained a caller in the demo itself (40008e7). The boot marches its 64x48 grid twice, unculled and with the scene enclosure, asserts in the binary that the two frames are identical pixels and depth, and prints both counts: 243,008 evaluations against 178,880, bound r 2.10, 668 of 3,072 rays proved empty. The line carries its own limitation, which is the useful part - 81% of the boot's rays miss but only 21.7% are proved empty, because a 2.1-unit sphere around a close scene fills the frame. That is the per-tile and per-node case arriving as a measured observation in the shipped demo rather than as an argument, and the next structure has a number to beat.

A frame now belongs to the caller, which is what AS4's promise needed to be checkable at all. render_into, render_culled_into, render_bnb_into and render_bnb_with draw into a Frame the caller keeps, and Frame::prepare clears and reserves without ever shrinking - so at a stable resolution the two buffers stop allocating after the first frame. examples/allocation_audit.rs prints that, and prints its own limit too: counting allocations would need a #[global_allocator], which is an unsafe impl, which unsafe_code = "forbid" refuses - the same ban that decided how assetc writes its bytes. So the audit distinguishes what it measures (buffer capacity that does not move across renders) from what it reads off the code (the bound table: two vectors per call unless the caller hoists it, which is exactly why render_bnb_with exists). Six tests in tests/frame_reuse.rs hold the sharper point: a reused frame is the same picture at every entry point, and a shrinking viewport reuses the bigger buffers rather than freeing memory the next frame asks for again. AS4 is now half measured - the CPU side

  • and its upload half still waits on OQ-51 with every other device claim in this phase.

The reload loop exists, and its design is the failure path

assetc watch [--once] recompiles an authored shape when its bytes change. The mechanism is the easy part; what is worth writing down is why the state machine is shaped the way it is. A watch loop that recompiles a broken file and leaves the last good asset on disk is a lie the engine then renders - the author deletes a shape twice and something else gets blamed - so Watch::observe fingerprints the authoring text instead of trusting an mtime (coarse enough to miss a save, loose enough to fire on a no-op), compiles before it touches the filesystem, writes to <output>.tmp and renames only on success, and on any failure returns a Failed outcome that leaves the previous asset intact and does not advance the fingerprint, so repairing the file rebuilds instead of being dismissed as already compiled.

Seven tests in crates/assetc/tests/watch.rs assert that behaviour without waiting on a timer - the first observation compiles, identical bytes report unchanged, an edit rebuilds, a malformed source leaves the old asset byte-for-byte intact with no stray temp file, a missing source is a readable refusal (editors delete-and-recreate, and some swap through a temp name on every save), and --once reports and exits non-zero so CI asserts instead of racing a poll.

Not claimed: AS1's own clause ends at the GPU buffer, which needs a device and waits on OQ-51, and OQ-48 still has no gate for any reload path. A passing state machine is not the gate.

Measured numbers now live in one manifest, checked from both ends

The boot's whole-output digest was quoted in five places and went stale the moment its raster line grew to report the scene cull - CI measured a new value, the prose kept the old one, and nothing noticed. That is a documentation defect of exactly the kind the repo's verification rules are about, and it happened while someone was fixing evidence, which is the argument for a mechanism over attention.

docs/evidence.json is that mechanism: one entry per measured number, recording the value, what it describes, which command measures it, which run first measured it, what it supersedes and which files must cite it. Two mechanical halves close it: tools/check_docs.mjs fails if a listed file stops containing the current value, or if a line cites a superseded digest without the current one beside it (there is deliberately no list of acceptable "history" words - a vocabulary rule can be satisfied by choosing a word, which is not a check), and the roots in a browser job fails if the digest it measures differs from the manifest. Both directions were proved before committing: the rule reproduced the drift immediately, and a planted stale citation is named on its line.

AS2 becomes a gate: three machines, one digest per asset

Doc 09 has always asked for "identical .bin assets from identical source on two machines ( deterministic serialization)" and nothing in the repository had ever compared two machines. CI has three, so each leg now compiles the same two fixtures, hashes the result, and a following job fails unless the digests form one identical pair set. sphere.ron alone would have been a weak witness - one node, four parameters - so knot.ron exists for this gate: twelve nodes five levels deep holding the values a parser is entitled to round differently (0.1, 0.3, -0.0, 1e7) and the union ordering the build-time normalisation derives from f32 sort keys. 38305051150 prints AS2 ok: 3 machines produced byte-identical assets from the same source: 4f9da1a4... and 52b85b4b... on x86-64 Linux, x86-64 Windows and ARM64 macOS.

The gate needed two fixes and a test of its own, and both bugs were in the checking rather than the serialisation. sha256sum on Git Bash prefixes names with * where Linux and macOS print two spaces, so the first version was red across three machines whose assets matched - failing in the worst direction a gate can. And the guard loop was written printf | while, whose body runs in a subshell, so its exit 1 aborted nothing: a truncated artifact would have passed. The comparison now works on normalised (hash, name) pairs inside a for loop, the failure message names the lists that disagree instead of blaming the assets, and tools/check_as2_gate.sh exercises the whole body against synthetic artifacts in three cases - agree (must pass), one machine diverging (must fail), truncated file (must fail) - as a step in the docs job, because a gate whose logic is untested is a decoration.

Gate P1.4 has its directory β€” and its cost says what the tiering doc must answer

Until 5a67cf3 the eviction paragraph in doc 03 was prose with no code behind it: nothing tiered, nothing evicted, and TierRestoreIncomplete was a variant with no producer. crates/ledger::tier is that code β€” a residency directory of caller-owned cells plus a hot_capacity, no allocator, no clock of its own (the caller drives tick, because TickId is consensus-relevant), and answers of AlreadyHot / Restored / Evicted { evicted } / NoRoom.

Two things were design rather than typing:

  • Two capacities, not one. How many regions may be hot is a different number from how many the table can remember. The first draft used one, so a third region could not be registered at all, its eviction dropped its arena range, and re-entry had nothing to restore β€” P1.4 failing in exactly the way the gate exists to catch, and failing quietly. Cold therefore means unreachable without a restore pass, never forgotten.
  • Everything is keyed on a digest. tests/residency.rs's eight tests do not assert that the same records come back in some order; they re-root the restored range and require the region's pre-eviction root, because that root is what a peer compares and what a save replays against. Ties break to the lower cell index so a replayed walk evicts the same regions twice, and an unknown region restores as None rather than as an empty range β€” "nobody edited here" must not be what a failed restore looks like. 388b035 gave the round trip a caller: the engine boot demotes region 0, re-enters it, prints the restored root, and that digest is docs/03's published REGION_0, pinned from outside the process by the_boot_brings_a_cold_region_back_as_the_root_it_left.

The cost half is the finding. cargo run --release --example re_entry_cost -p ledger re-roots a full slab-sized region: 68,524 digests, which is spike S2's own published count for that many edits, and CI runs the step on all three architectures (38279591441) where the count repeats identically while the price per digest does not β€” 757 ns on ARM64 macOS, 486 on x86-64 Windows, 302 on x86-64 Linux, i.e. 51.9 / 33.3 / 20.7 ms for one region. So re-entry is not a new problem; it is S2's hashing pass moved to a different moment, triggered by walking back instead of by closing an epoch. And on every runner the harness has, a single region's restore is at least one of doc 04's 16.7 ms frames β€” while a camera does not re-enter one region, it re-enters the ring around it.

That closes off one design path by measurement rather than opinion: recompute-on-re-entry cannot fit the frame at any interaction radius. The lever left is whether M_cold stores each region's root beside its records β€” re-entry then costs a copy β€” which doc 03's tiering table does not currently say, and which is OQ-49 arriving from the read side instead of the commit side. Per-edit time falling as regions fill (8.5 Β΅s to 6.0 Β΅s) is not a saving to chase: ns per digest is flat, and the drop is scattered leaves sharing upper paths β€” 94% then 69% of the 12N bound β€” so splitting one large restore into several small ones multiplies the fixed part rather than removing it.

P1.4 is still open, and says which half: correctness is enforced, cost is measured on three architectures, and pop-in remains a frame-time word that needs a renderer to be visible in.

The boot's roots now run inside a browser β€” and the blocker that was named for it was not one

a4c22a4. The gate P1.5 note on this wiki said the M1-browser row "still needs a browser", and the reason on record was the export-lint decision: a page would have to call an exported function, and this workspace forbids writing one. Both halves of that reasoning were checked by building the thing instead of repeating it, and both fell.

A WASI command module exports _start because the Rust runtime emits it, so a page needs no export from this repository β€” only the host functions the module imports. tools/wasi_shim.mjs is that shim, and the refusals are its substance: fd_read answers EBADF and path_open answers ENOENT, because a shim that quietly opened files would be running a different program than the boot. Before any browser was involved, tools/shim_parity.mjs ran the module through that shim under bare V8 with no node:wasi at all and reproduced the native release boot byte for byte β€” the control that makes a browser result mean something, because without it a disagreement could always have been a bad shim. Then tools/browser_parity_server.mjs served the same two files from disk to a page, and the page's own WebAssembly API printed the same fourteen lines. Three hosts, one SHA-256 over the whole output: 15c1e8af1b523d73… β€” native process, bare V8 plus shim, browser page.

That pair is a CI job now (roots in a browser, on all three architectures, green at 38283568760), and getting there produced a lesson worth keeping: the first version of the shim remembered the twelve imports a local build asked for, CI's toolchain built a module importing a thirteenth (fd_fdstat_get), and run 38282729718 failed all three legs on a LinkError. The harness was broken on the machine that was supposed to be measuring the boot. So importObject derives the set from WebAssembly.Module.imports and records every name it had to refuse rather than implement β€” CI prints stubs called: none β€” and the job runs the V8 control before it is allowed to be interesting. A runner with no browser prints SKIP instead of quietly passing.

The limit that was written here at the time β€” "every browser available here and in CI is Chromium, so what closed is browser as host and what remains is a different wasm engine" β€” was correct and only lasted a few commits, because the engine turned out to be reachable too. tools/safari_parity.sh enables the safaridriver that ships with macOS, opens a WebDriver session with nothing but curl, points Safari at the same page and compares the digest the page reports. On the ARM64 runner (image macos-26-arm64) run 38297963941 prints OK Safari/JavaScriptCore produced the native digest, 15c1e8af1b523d73… β€” the same bytes the native boot, the bare-V8 shim and the three Chromium legs agree on.

So gate P1.5's determinism claim now rests on two independent WebAssembly implementations, WebKit/JSC and V8, on both x86-64 and ARM64, rather than on one implementation wearing four hats. What is still honestly open is the hardware: N2600, RTX 3060 and a Linux-Mesa machine, and the fact that a CI runner's Safari is not somebody's M1 MacBook. The harness pays for that with one more job β€” eight definitions, fourteen runner-level runs β€” and the leg's first attempt was red because the script slept two seconds and then asserted that a server it had not waited for was dead, which is the same lesson as gating on a browser's exit code, learned twice in one day.

Compaction is an implementation now, and it moved a gate question in the unexpected direction

crates/ledger/src/compact.rs (commit 081137c, with the lint fix 3c805b5) is doc 03's bake as a data transform: Edit { rank, record }, voxel_of (drop the payload bits and the packed word is the position), assign_ranks for callers using append order, compact collapsing each voxel run to its greatest rank in place, write_records handing the survivors back. No allocator, no allocation, and the root preserved by construction β€” because the frozen A-10 shape hashes a voxel leaf over its payload alone and resolves duplicates at the leaf by greatest append, so a region root depends on which payload won and on nothing else about the list. Five tests in tests/compaction.rs assert that rather than argue it: digest equality, idempotence checked against the survivor set (a right-length answer with a duplicate and an omission would pass a length check), the winner following arrival order under three permutations, a duplicate-free region passing through untouched, and short buffers refusing in both directions.

Then examples/compaction_ratio.rs measured it at spike S2's scale β€” 2,048 distinct voxels, 1 to 16 writes each, both of S2's arrangements β€” and the honest result has two halves. Digests: identical in every row (18,489 scattered, 4,096 clustered), so hashing is priced by distinct voxels and doc 03's 12N was always a bound on leaves, not records; that was the first reading, "compaction is not a lever on the Merkle pass". Wall time: not flat, because the walk partitions its leaf slice per entry at each of twelve levels, so compaction recovers roughly 1.6x of a scattered pass and 2.7x of a clustered one at 16 writes per voxel, and nothing at 1-2. For the workload gate P1.3 was quoted at β€” a full slab of distinct scattered edits, where live == records and the transform is the identity β€” the option is still empty, and the floor it exposes is ~13 ms for 2,048 voxels against a sub-millisecond target. What compaction buys unconditionally is capacity: 16x fewer records in an arena, 16x fewer leaf-scratch entries in a commit, same root. That makes it a SPEC-06 and cold-tier instrument first, and it is why OQ-49's candidate list is now target, budget, scope β€” with frequency partial at best.

The descent is the third culling layer, and it is worth 443x on the rows one bound could not move

The section above left the spread-scene rows at 0.0% of rays proved empty and said the next structure had to be more bounds rather than a better one. SceneBounds::may_reach_subtree (commit 61491cc) is that: the same sphere question, asked of each child, stopping at any node the bound rule does not license β€” a smooth union (its field dips below its own bound), an unbounded kind, a counting table that must stay a baseline. Combinators contribute only the rule they actually grant: a hard union's zero set is the union of its children's, so either child reaching is enough; a subtract or intersect has a field at least its first operand's, so it inherits that operand's reach; a mirror duplicates surface into the reflected copy, so the descent stops there.

The measured difference is not incremental. On exactly the rows where the single root bound proved 0.0% of rays empty β€” 32 and 128 spheres along a line, both scatter arrangements β€” the descent proves 96 to 98%, and the 128-sphere line's charged evaluations fall from 287,112 to 648, a factor of 443, on a frame asserted byte-identical. That is ADR-024's hierarchy measured rather than asserted, and the first number in this repository that says what a BVH is worth instead of only that one is due.

It also produced the bug this module's whole design exists to prevent, caught by the frame comparison and not by reading the arithmetic. The first version tested each sphere's bare radius. A grazing ray that passes within surface of a sphere yet outside it is a hit as far as the marcher is concerned β€” the march stops wherever |field| < surface, and a sphere's field is distance minus radius β€” so the 8-sphere cluster row disagreed with the unculled frame by one pixel and one hit. A skip is a claim about the marcher's tolerance, not about geometry, and every enclosure consulted for one is now grown by surface.

The seeded variant β€” return the entry distance and start the march there instead of at the camera β€” was built, measured, and removed on purpose. It saved 8 march steps out of 85,508 on a mirror row while shifting 4 to 12 pixels per frame by up to 3x surface: the hit set identical, the converged points not, because a trace starting later walks a different sequence of steps. Byte-identity is the instrument behind every number above and behind the three-architecture boot diff, so trading it for a saving two orders of magnitude smaller than the skip's own is a renderer policy decision, recorded as OQ-53 rather than taken locally.

What a blend costs: the culling licence is an authoring decision, now priced

cull_probe runs the same scene twice β€” identical spheres, identical tree shape, identical camera β€” with every branch written as a smooth union (k 0.2) in one version and a plain union in the other:

128 spheres Branch kind Rays skipped by the root bound Rays skipped by the descent Evals after the descent Node work avoided
line plain union 0.0% 97.1% 648 98.3%
line smooth union 0.0% 0.0% 287,128 0.0%
cluster plain union 44.3% 93.8% 1,656 97.5%
cluster smooth union 32.8% 32.8% 180,662 0.0%

A skip needs field >= distance to the node's own bound, and smin returns below min by up to k/4, so a blend poisons that precondition for as far up as it reaches β€” one blended branch at the root costs the whole hierarchy, a factor of 443 on the line row, and the per-node skip goes to zero with it. The frames stay byte-identical in every row, which is what makes this a price rather than a bug.

The demo now says it out loud: the boot marches its grid a third way through the descent and prints the scene's own licence bit beside the counts, and because the boot's five-node scene unions a rounded box with a smooth-union pair, it prints root licence refused next to two identical culled counts (179072 evaluations, 666 of 3072 rays proved empty either way). Measured, not read off the code.

Two things follow. For the asset pipeline, blending is a frame-cost decision and not only a look decision, and nothing in the current grammar records that in the file.

The licence was then measured, and the refusal stands anyway. the_smooth_union_licence_is_measured_and_not_yet_relaxed samples four scenes - two lone blends (k 0.4 and k 1.5), a plain union as the licensed control, and the boot's own union-over-blend shape - on a 41-cubed lattice at 0.25 spacing, plus dense 0.005 sweeps along the lines where smin dips hardest (the axis through both centres, and both waist lines). Every blended scene keeps field >= distance to its grown enclosure outside it, with a worst margin of exactly +k, zero violations larger than the render tolerance, and zero points inside the marcher's hit band lying outside the bound. The growth by k absorbs the k/4 dip and leaves k over - so the refusal is not needed for containment on these scenes, and relaxing it is a one-line change worth the 443x above.

It was not flipped, and the reason is written in the test. A lattice is not a proof, and the claim is about every point in space. And the licensed plain union itself reaches -0.000000 at its far corner - its bound is tight to f32 rounding - which is why violations are counted relative to surface rather than against zero: a rounding-scale miss sits inside the band the marcher stops in anyway, so it cannot delete geometry. OQ-54 now carries both options: derive the bound from the quadratic smin actually implemented (the same formula the WGSL twin's text guards pin) and relax with the derivation written down, or keep the refusal and tell authors plainly that blending costs the hierarchy.

OQ-54 closed: the licence was earned, the saving was an arm

The two halves of the smooth-union question came apart under measurement.

(a) The licence was owed to a blend. The sphere stored for a smooth union is grown by k while smin dips below min by at most k/4, so outside the grown sphere field >= min(a,b) - k/4 >= D_cover - k/4 = D_grown + 3k/4. respects[NODE_SMOOTH_UNION] is now first_ok && second_ok (commit 22986bb) - and granting it changed cull_probe's blended rows by nothing, which retracts the claim published with the licence table that relaxing it was worth the 443x. A blend's zero set lies between its children, outside both child spheres, so no descent may decompose a blend: the grown cover sphere is the tightest statement a blend can make.

(b) The saving was an arm a blend can take. smin(a,b,k) equals min(a,b) exactly when h = max(0, 1 - |b-a|/k) collapses to zero - once the children are k apart the blend has nothing left to add - so eval_at now skips a second child whose licensed bound exceeds a + k (commit da0d470). The comparison is strict: a + k in f32 can sit a rounding step below the true separation, and h^2 k/4 is then tiny but nonzero - an overestimate of the field is exactly what a sphere march cannot afford. And k.max(0.0), because a non-positive strength makes smin a plain min and the threshold must then be the union arm's a.

Blended node work avoided, on the four rows that read 0.0% before:

Blended (k 0.2) Spheres Visits unpruned Visits pruned Work avoided
cluster 32 688,086 548,603 20.3%
line 32 1,277,892 820,930 35.8%
cluster 128 3,898,950 1,336,864 65.7%
line 128 5,172,420 2,814,446 45.6%

Frames byte-identical throughout. The belt is asymmetric on purpose: the new test also runs a tight-blend scene (children 0.2 apart under a strength of 1.5, never k apart anywhere the camera reaches) and requires it to avoid nothing, so a blanket skip fails as hard as a dead one. For doc 09, what an author gets is a proved skip inside each blend and no hierarchy through one. The boot's printed line did not move, so the published digest stays c6c4ec046bec1260.

The demo got a scene, and ADR-024 became visible in the shipped binary

The boot's scene used to be one compact object. Its single enclosure was already tight, so the finding cull_probe published - one bound saturates the moment geometry spreads - was only ever visible in an example nobody runs. Commit c04ad6e gives the demo a scene: the hero (a rounded box unioned with a smooth-union pair) plus three spheres spread across the space, joined by plain unions. Eleven nodes, depth 4, 348 asset bytes instead of 204 - and the descent, assetc's median-split rebalance and the blend's k-aware skip arm now have something real to work on inside cargo run -p engine.

What the boot prints is the disagreement the hierarchy exists to win:

scene raster     : 64x48 grid marched on a CPU - 605 of 3072 rays found surface, 241779 evaluations,
                   241779 with the scene cull (one bound r 10.79, 0 of 3072 rays proved empty),
                   177843 with the hierarchy descent (666 proved empty, root licence granted)

The single bound's radius grew from 2.10 to 10.79 and its proofs fell to zero - the line arrangement in miniature, arrived at in the demo rather than in a probe - while the bound hierarchy proves 666 rays empty and removes 26.4% of the frame's charged steps, with all three frames asserted pixel- and depth-identical inside the binary. The hero's probe ray still lands at t = 3.0070 after 8 evaluations, which is the invariant that says adding distant geometry did not move the near surface, and the hand-assembled cross-check grew to the same eleven nodes so the lowered field and an independent construction still agree.

605 of 3072 rays find surface (was 590). Nothing hashed moved - epoch root, region list, slot table and the re-entered REGION_0 are docs/03's published digests - and the boot's fourteen-line output hash retargets to 649eff38a5529b1b… with the reason recorded in docs/evidence.json.

The frame work budget: a ceiling that holds, and what it actually costs

render::raster::render_bnb_budget (commit 731e66d) draws as much of a grid as a stated number of charged evaluations pays for β€” the same unit Stats::evaluations reports, so a budget and the number it produced are comparable without a translation step. The decision is made against max_steps + 1, the worst a ray can cost, before the ray starts, which is what makes the ceiling a bound rather than an intention. Pixels past the cut carry NOT_DRAWN_DEPTH/NOT_DRAWN_RGB and Stats::not_drawn counts them: "never marched" and "marched and found nothing" are different facts.

examples/budget_probe.rs β€” an informational CI step, integers only β€” prices the curve on a 64x48 grid where the unbounded hierarchy render costs 182,144 charged evaluations and finds surface on 1,000 of 3,072 rays:

Budget Pixels drawn Undrawn Surface kept
1/16 203 2,869 0.0%
1/4 673 2,399 0.0%
1/2 1,511 1,561 36.4%
3/4 2,573 499 100.0%
full 3,068 4 100.0%

The loss is not linear in pixels: a quarter of the work keeps nothing visible, because the walk is tile-major from the top left and the top-left tiles are sky, while a quarter off the ceiling drops 499 pixels and every single hit survives. So a ceiling is a statement about the frame's composition, and the walk order is policy β€” the version a real renderer wants is centre-out, keeping the subject and sacrificing the corners. The full row is honest about conservatism: a budget equal to the measured total still leaves four pixels undrawn, because those rays reserved the worst case and spent less. Seven tests pin the ceiling at nine levels, the byte-identity of everything a cut draws, monotonicity, tile-boundary prefixes and run-to-run equality; none of it is a frame time, and gate R1 still waits on a GPU.

The budget walk order, measured: same ceiling, 0% or 25% of the scene

render::raster::Scan (commit 2f05c82) makes the visit order an argument on the ceiling call: TileMajor (blocks of eight, top-left first β€” the documented default) or CentreOut (tiles sorted by integer squared distance from the frame's centre, ties by row then column, so the sequence is a total order that cannot depend on how a backend rounds a square root). budget_probe runs both at every ceiling on one scene, one bound, one march:

Ceiling Tile-major: drawn / surface kept Centre-out: drawn / surface kept
1/16 203 / 0.0% 352 / 25.0%
1/8 322 / 0.0% 789 / 59.0%
1/4 673 / 0.0% 1,147 / 72.6%
1/2 1,511 / 36.4% 1,840 / 97.0%
3/4 2,573 / 100.0% 2,389 / 100.0%

At a sixteenth of the frame's work, the order is the difference between an empty image and a quarter of the scene. Centre-out also buys more pixels for the same money at the low end, because it reaches the provably-empty tiles earlier and a skipped tile costs nothing to decide β€” which corrected an assumption the probe had been asserting: equal ceilings do not buy equal pixel counts, since tiles differ in cost. That is the finding, not a bug: a ceiling spends against the frame's composition, so the walk order is a content decision. Nine budget tests now include both orders drawing a prefix in their own order with every drawn pixel equal to the unbudgeted frame, and centre-out keeping strictly more surface at an equal ceiling.

173 tests in 27 suites. Trunk note worth keeping: CI's clippy is 1.99 and the laptop's is 1.92, so a constant-size chunks_exact_mut that checked clean locally red-trunked three architectures on a lint that does not exist in the older toolchain β€” fixed in 2b91b29 by the boring construction.

The demo draws under a ceiling: half the work, 99% of the scene

Commit 362a060 gives the boot a fourth march of its 64x48 grid β€” half the hierarchy render's charged work, spent centre-out β€” and it prints:

scene budget : ceiling 88921 of 177843 charged evaluations - 1475 pixels drawn,
               1597 left undrawn, 599 of 605 hits kept, centre-out

Half the frame's work keeps 99.0% of its surface. That sentence is what gate R1 will eventually be priced with and what AS5 will eventually reject an asset by, so it belongs in the shipped binary rather than only in an example. Three things are asserted inside the boot, not printed and hoped: the ceiling was not overstepped, the pixel bookkeeping closes, and no pixel the budget paid for differs from the unbudgeted hierarchy render's.

The output is fifteen lines now, and CI run 38322714045 is green on all ten job definitions β€” native, the bare-V8 shim, Chromium on three architectures and Safari on JavaScriptCore all print the same bytes, with the digest 167a0cade1327d0e… recorded in docs/evidence.json. That makes the position of the cut a cross-implementation agreement: the ceiling lands in the same place in every engine, which is the renderer's version of what gate P1.5 claims about the ledger. Nothing hashed changed.

Worth the scar tissue: getting there took three red CI runs, because CI's clippy is 1.99 and the laptop's is 1.92 β€” a constant-size chunks_exact and a division guarded by a zero test were errors there and invisible here. The lesson in the hygiene memory is now "write the boring construct a newer linter cannot flag", and the evidence-citation guard was retuned from per-line to per-file for the same reason: a check that produces thirty findings per retarget gets switched off, and a switched-off check guards nothing.

Region streaming landed on the CPU path, and it saved something different than expected

crates/render/src/stream.rs (commit 3ab8557) renders a world of regions: one compiled shape per region cell, placed at cell * 4096 in gate C1's arithmetic, composed as the minimum of their fields. That composition is forced rather than chosen β€” A-07 has no translation node (repeat tiles at a fixed period, mirror reflects about zero), so two authored shapes in different cells cannot be one tree. Whether the grammar should grow an instance kind is OQ-55, filed as a contract decision with its cost attached: an interpreter arm, a validator rule, the WGSL twin's agreement, and the asset-graph question an instance implies.

examples/region_cost.rs β€” now an informational CI step, so its asserts run on three architectures β€” corrected the assumption before it was published:

Regions in world Planned Charged evaluations Bound checks Frame changed
1 1 1,248 3,072 no
7 1 1,248 21,504 no
21 1 1,248 64,512 no
64 1 1,248 196,608 no

The march does not cost the world at all β€” identical charged work and byte-identical frames from one region to sixty-four β€” because every region already carries a per-ray bound. What streaming removes is the test: one sphere check per region per ray, which is the term that scales with the size of the world rather than the screen. Three tests in tests/stream.rs hold the picture fixed: a single region at its own cell equals the single-tree renderer pixel for pixel, depth for depth and charge for charge; a region out of range is invisible whether or not it is planned, with the plan proven non-vacuous by a range that does reach it; a seven-cell world frames identically under a one-region plan. The per-ray region test grows by the marcher's hit tolerance for the reason the cull already does β€” a grazing ray is a hit to the march, and a skip that disagrees with the march is a missing pixel.

The demo spans three region cells, and OQ-57 turned out to be an assertion that could not be true

examples/region_diagnose.rs (1e1efe6) ran the demo's own multi-region configuration and varied one factor per row. The result killed the mechanism this page previously carried: the hit set is identical in every configuration β€” 56 of 3,072 rays, prop cell in or out, 96 steps or 512, slab ridge or sphere ridge, either grid size. Nothing is starved by composition. What differs is a stopped depth in the third decimal (7202.292 against 7202.295), which at that magnitude is three f32 units in the last place and well inside the 1e-2 tolerance that defines a hit: composing cells changes the trajectory, so the trace settles elsewhere in the same band. The per-cell interval traversal written to "fix" it was implemented, measured β€” it changed nothing β€” and deleted.

The shipped consequence (e1087dc, CI green on all ten job definitions at 38326245766) is step 9 of the boot: three region cells β€” the hero's prop-scale spread, a 900Γ—120Γ—900 ridge, a 500-unit hill β€” each compiled through the same lower β†’ bytes β†’ reader path as the hero, composed into one frame, printed as

region world : 3 cells +4096 units apart, all planned - 56 of 3072 rays found surface,
               19000 charged evaluations, 2824 proved empty by their bounds; dropping the
               prop-scale cell leaves the hit set identical and moves no stopped depth by
               more than 0.005 at distances up to +12288 units

with the assertions stated as they can be true: identical hit sets, stopped depths within four times the tolerance, all three cells planned. That is the rule to carry forward β€” across region cells a frame compares in pixels and hits, and in depth only to within the march's own tolerance β€” which is OQ-53's seeding finding and the WGSL twin's 2.4e-7 cross-vendor deviation arriving from a third direction, and one more reason gate R4 stays informational. The sixteen-line output, digest a651b7f33e2ac0c6…, is now what native, bare V8, Chromium on three architectures and Safari/JavaScriptCore all agree on.

1080p, measured and derived: what a frame ceiling actually buys

Gate R1 ("1080p/60") and AS5 (a compile-time ceiling) want the same missing number, so examples/frame_cost.rs measured it on the demo's own scene and kept the three kinds of figure apart.

  • Measured on this CPU: ~78 charged evaluations per pixel, flat across 96x72 / 192x144 / 320x240 (78.02 / 78.08 / 78.15) at 25.5–26.1 ns per evaluation. Resolution costs work; it does not change work per pixel β€” that is the law everything else is extrapolated from.
  • Derived, labelled as such: an unbounded 1080p frame is ~1.62e8 evaluations, ~4.1 s here, ~248Γ— R1's 16.7 ms. Arithmetic on a CPU reference renderer, not a GPU frame time, so R1 stays unmet by this file rather than answered by it.
  • Measured at 1080p under ceilings:
Ceiling (charged evaluations) ms Pixels drawn Hits kept
2,000,000 378.3 126,493 36.2%
8,000,000 806.0 279,383 67.3%
20,000,000 1,220.3 467,894 89.1%
45,000,000 2,164.0 758,375 100.0%

So the ceiling is the dial between "mostly sky" and "kept its subject", stated in the same unit the compiler rejects on β€” which is why AS5 can be armed the moment someone picks a row, and why no row was picked here: choosing one is R1's frame-time call and R1 has no GPU (OQ-51). One correction made on the way: the reference is the ceiling-free hierarchy render, not an uncullled one β€” at 2.07 million pixels the difference is minutes of runtime for no difference in the picture.

Pop-in becomes a frame count: the ceiling spread across frames, losslessly

render_bnb_refined (commit 2ec471a) spends a frame's ceiling over several frames: existing pixels stay, the ceiling is charged only on what is still NOT_DRAWN_DEPTH, and the walk resumes where the last cut stopped. The legitimacy test is byte identity β€” a frame finished across passes equals one finished at no ceiling, cumulative hits land on the single-pass count, coverage strictly grows, no pass overruns (tests/budget.rs). A budget that changed the picture would be a rendering mode, not a schedule.

That converts gates R3 and P1.4's "no visible pop-in" into a number available before any display exists. Demo region, ceiling = 1/4 of the culled frame's charged work:

Scan order Frames to complete First frame's share of the hits
CentreOut 5 25.1%
TileMajor 9 1.4%

Same ceiling, same field, same march β€” the order alone is a 5x difference in when the subject appears, which is why centre-out is the default for transitions. The word "visible" still waits on a display, and the display on the wgpu licence decision (OQ-51); what no longer waits on either is the count.

A far region cell was rounding geometry away, and gate R5 found it (2026-10-13)

R5 says "zoom to 1e-6 world units on a pig snout with no polygonation". The test the crate had for it marched the scene at the origin, which is the easy case and not the claim. examples/deep_zoom.rs now runs one snout β€” a body sphere of r 1.0 with a bump of r 0.02 in front β€” through a 64x64 grid at surface = 1e-6, four ways, against the local baseline:

regime hits of 4,096 max depth delta mean normal angle (rad) worst normal angle (rad)
local (the origin) 4,096 0.000000 0.000193 0.000846
far, camera-relative 4,096 0.000000 0.000193 0.000846
far, absolute 4,096 0.004965 0.071092 0.145554
far, region cell (244,140) 4,096 0.000000 not shaded β€”

Row 3 is doc 02's rule stated as a price. The number to watch is the normal, not the depth: a polygonated snout still reports a plausible distance.

Row 4 was a bug in shipping code when it was first measured. crates/render/src/stream.rs formed the world point from the absolute camera position and subtracted cell * 4096 afterwards. At cell 244,140 the origin is 999,997,440 and the f32 ulp there is 64 units, so camera.origin + dir * t rounded back to camera.origin for every t under 32 and the subtraction returned a flat zero β€” the snout collapsed along the axis the cell lay on and 1,280 of 4,096 pixels missed (2,816 hits, 0.034 depth delta). The fix is the ordering doc 02 already required: Region::relative_origin subtracts the cell from the eye first, where IEEE-754 gives the difference exactly for two floats of comparable magnitude (Sterbenz), t then accumulates in the region's own frame, and enclosure_relative keeps plan and the per-ray bound out of world coordinates as well. After it, row 4 is row 2 β€” every pixel hits and every depth matches the local baseline bit for bit. tests/stream.rs::a_cell_a_billion_out_draws_the_same_picture_as_the_same_cell_at_zero pins pixels, depth, hit count and charged work.

OQ-56's retirement of "the camera-relative-placement worry" is corrected rather than deleted: its angular-size half stands (a sub-pixel object four cells away should render as nothing), but its precision half rested on a probe that stopped at 12,288 units, where the ulp is 0.00049 and this mistake is invisible. The remaining question β€” how far from world zero an f32 render path may go, and whether that number belongs in contracts β€” is OQ-58, and it is the Architect's. R5 stays met in part: the gate is about an image on a display, and there is no device yet.

The same example then answers the question the fix left open. Sweeping the cell index from 0 to 2^28 + 1 β€” world zero to 1.0995e12 β€” every row returns 4,096 of 4,096 hits with a depth delta of exactly 0.000000: with the subtraction ordered correctly the frame does not care where in the world it sits, because the eye and the cell origin cancel exactly and t never sees a large number. What degrades is placement, which is the quieter failure:

cell index world origin origin βˆ’ exact i64 (voxels) f32 ulp there (voxels)
1 4,096 0 0.0005
4,096 16,777,216 0 2.0
244,140 999,997,440 0 64.0
16,777,217 68,719,476,736 βˆ’4,096 8,192.0
268,435,457 1,099,511,627,776 βˆ’4,096 131,072.0

So the two thresholds are 2^24 world units (cell 4,096), past which an f32 origin cannot name a fraction of a voxel, and 2^24 cell index, past which the index conversion itself is wrong by up to a whole region. Nothing in a frame reports either: the picture stays perfect while the shape sits somewhere near where the ledger says it is, which is why the number belongs in a contract rather than in whichever subsystem notices first. The third figure is R5's own β€” an f32's ulp crosses 1e-6 near 16 world units, so the gate's tolerance is unreachable in absolute coordinates beyond that, and camera-relative arithmetic is the only thing that makes it reachable a billion units out.

The sweep runs in CI on all three architectures (step Deep zoom, four coordinate regimes, run 38332751812) and the runners print the same table digit for digit. Stated narrowly on purpose: these are presentation floats and nothing hashes them, so the agreement is not a P1.5 clause and adds no device to S3's list. What it establishes is that the ordering rule is compiler-independent β€” a WGSL twin has no rounding excuse for disagreeing, and the deleted-geometry bug above was a real bug, not one machine's arithmetic.

The hierarchy moves inside the region, and the demo now pays for it (2026-10-13)

cull_probe priced ADR-024's ray-level descent on one tree and region_cost priced what a per-region bound saves across cells. Neither priced the case a streamed world actually is: a cell spread wide enough that its own cover sphere swallows the frustum β€” the cull_probe rows that prove 0.0% of rays empty β€” so the coarse test does nothing and the children are all that is left. stream::render_bnb now asks the descent of each region's tree, in that region's coordinates from that region's eye (the same ordering rule as the fix above: a bound table is a statement about the tree's frame, not the world's), and render_with_tables(cull::counting_bounds) runs the identical walk that prunes nothing, so the saving is a measured difference rather than an argument. One cell, a 64x48 grid, 3,072 rays:

ridge nodes plain evaluations with the descent rays proved empty node work avoided
9 spheres, spacing 1.2 17 229,888 5,632 0 β†’ 2,336 85.6%
9 spheres, spacing 8.0 17 283,884 876 0 β†’ 2,948 97.4%
33 spheres, spacing 8.0 65 283,884 876 0 β†’ 2,948 96.4%
65 spheres, spacing 6.0 129 294,912 11,904 0 β†’ 2,948 96.0%

Three readings. The prize is bigger than node work: a ray the cell's cover sphere permits but no child can reach is proved empty before the march starts, so it is charged nothing β€” the evaluation column moves, not only the visit column. Cost stops tracking the world and starts tracking the neighbourhood: 33 spheres cost exactly what 9 spread spheres cost, the same 876 evaluations, because 24 of them are never entered. And it is not free or universal: the tight row buys 85.6% instead of 97.4%, every frame pays a bound table per selected region (O(nodes), once per frame rather than per ray), and none of this is a frame time β€” R1 still waits on a device (OQ-51).

Step 9 of the boot marches its three-cell world both ways and asserts the two pictures are one pixel for pixel: 174,881 charged evaluations and 1,039 rays proved empty by the cell bounds, against 57,665 and 2,260 with each region's children asked. The hero's t = 3.0070 did not move, and no hashed value did either β€” EPOCH_0, REGIONS, SLOT_TABLE and the re-entered REGION_0 still agree with doc 03, and the WASM leg still matches the native build line for line. Only the whole-output digest moved (d66eff19… β†’ 594a8b1cc9add7d2…) because a printed line grew; docs/evidence.json carries the new value with the old one superseded and the reason attached, which is what the CI browser and JavaScriptCore legs now print.

The test that owns the law is one-directional: pixels, depth and hits equal, evaluations and proved-empty rays allowed to move only in the safe direction. Its first version asserted evaluations equal β€” saving confined to node visits β€” and failed, which is the measurement correcting the expectation rather than the code. CI's Format step caught a second mistake on the same unit: the boot was edited after the last cargo fmt, so a local clippy-and-test pass was green while all three runners went red. A local pass is not the trunk gate.

The demo now performs the carry, and it cost nothing but frames (2026-10-13)

Step 9b of cargo run -p engine refines one streamed frame of the shipped three-cell world at a ceiling of one ray charge (max_steps + 1 = 97) until nothing is short of, and asserts inside the binary that the settled frame is pixel-for-pixel and depth-for-depth the picture the unbudgeted descent march found. Measured on that world: 813 frames, cumulative charge 57,665 β€” exactly the single pass's, with 2,260 rays proved empty along the way. CI's three implementation legs (native, Chromium, Safari/JavaScriptCore) all print the resulting seventeen-line output as the same digest 3c67db5f51644de6…, so the demo's shape is a cross-implementation agreement, not one machine's stdout; no hashed value moved.

Two conclusions, and the second is a defect-shaped finding rather than a win:

  • Refinement is free in work and expensive in frames. The carried total lands on the single pass's charge to the unit, so a ceiling is a statement about latency to a complete frame β€” the thing a pitch demo is actually judged on β€” not about cost.
  • The ceiling is charged before the reachability test, so free rays go uncollected. The gate asks whether the next ray could fit its worst case, and only then whether it hits anything; once the answer is no, the frame stops, leaving thousands of zero-cost pixels waiting for the next frame. Frame one settles a single ray out of 3,072. Reordering is arithmetically sound (a proved-empty ray cannot breach a ceiling) but changes which pixels are drawn at a given ceiling, which is exactly the prefix property tests/budget.rs exists to pin β€” so it is filed as OQ-60 rather than slipped in as a quiet edit.

P1.4's pop-in clause, measured on the arriving region's own pixels (2026-10-13)

The gate's wording is about the picture a re-entered region contributes β€” "shows prior edits with no visible pop-in" β€” and every number we had counted pixels, not whose pixels. examples/region_pop_in.rs attributes them: render the composite with the re-entered cell and without it, difference the depths, and watch only that footprint fill as the frame is refined. On the demo's geometry (a 400-unit hill one cell over, owning 70 of 3,072 pixels β€” 2.3%, which is OQ-56's angular-size fact in a demo costume), at a one-ray-charge ceiling:

order pixels settled by frame 1 of which the arriving region's footprint 50% footprint complete frame complete
centre-out 8 0 frame 41 frame 76 frame 111
tile-major 224 0 frame 75 frame 110 frame 111

Two conclusions the frame average hides. First, "the frame is done" is the wrong question: what arrives first is the cheap, provably-empty majority, and in either order the region you just paid a full Merkle pass to restore contributes nothing to frame one. Second, the walk order that made no difference to frames-to-finish (57 frames either way, on the synthetic ridge) makes roughly a 2Γ— difference to the arriving region's own surface β€” so OQ-60's reorder has to be judged on a region-attributed measure, because the two metrics disagree about what a good first frame is and only one of them is what a player sees.

The instrument runs in CI as an informational step beside the carry curve; the Linux, Windows and macOS runners print the same table. "Visible" pop-in still waits on a display and therefore on OQ-51: these are frames, not milliseconds.

The same ceiling, spent across cells β€” mirrored from docs/04 (2026-10-13)

The budget sections above spend a ceiling inside raster, which owns one tree. The frame the demo composes is a minimum of several regions, and a ceiling across cells is a different claim unless it is the same law β€” so stream::render_budget spends it the same way: the decision is made against max_steps + 1, the worst a ray can cost, before the ray starts; tiles are walked in the order the caller asks for; a pixel the cut never reaches reports Frame::NOT_DRAWN_DEPTH / NOT_DRAWN_RGB rather than a plausible sky. It is not a second march β€” the per-ray work moved into one private march_ray that the plain, descent, counting and budgeted walkers all call, because two copies of the composite march is exactly the fork that would later show up as "the budgeted frame disagrees with the full one". tests/stream.rs's ceiling test is the gate: ceilings from zero to twice the full cost, none overrunning, every drawn pixel equal to the unbudgeted frame in colour and depth, rays == drawn + not_drawn at every level, and an unlimited ceiling byte-identical to the descent's frame in both buffers.

On the same wide ridge the descent prices (33 spheres, spacing 8.0, one cell, 3,072 rays), fractions of that frame's own unbudgeted cost:

ceiling order charged pixels drawn rays proved empty surface kept
1/8 centre-out 13 22 21 0.8%
1/8 tile-major 13 1,238 1,237 0.8%
1/4 centre-out 129 43 29 11.3%
1/4 tile-major 129 1,259 1,245 11.3%
1/2 centre-out 343 106 60 37.1%
1/2 tile-major 343 1,322 1,276 37.1%
3/4 centre-out 564 158 75 66.9%
3/4 tile-major 564 1,758 1,675 66.9%

Coverage differs by 11Γ— to 56Γ— and the share of surface kept does not differ at all. The reason is the descent itself: a proved-empty ray is charged zero, so a walk that spends its ceiling on cheap tiles finalises most of the grid while barely touching the budget, and the expensive rays near the subject are the same handful whichever tile it starts from. pop_in's single-tree case β€” a scene that fills the frame, where no per-region test throws the sky away β€” produced the opposite advice from the same law (25.1% of hits in the first frame centre-out against 1.4% tile-major). So on a streamed frame with the descent working, walk order decides how fast the background settles, not when the subject appears, which is why the order stayed policy rather than becoming a default tuned to one fixture.

Two facts follow, and both cost a failed attempt to learn:

  • A ceiling below one ray's worst case draws nothing, and that is correct. The gate is decided against max_steps + 1 before the ray starts, so a budget smaller than one ray cannot afford a single ray: the frame stays entirely at NOT_DRAWN_* and charges zero. That is what makes the ceiling trustworthy as a gate rather than a statistic β€” and it is also indistinguishable from a stalled resume, which is what the first refinement attempt was mistaken for. Consequence for callers: specify ceilings in ray charges, not as a fraction of a frame. full/8 of a 32Γ—24 grid is less than one ray, and a caller who picks that number will watch a renderer do nothing and assume it is broken.
  • Frames to finish do not depend on walk order, even though pixels per frame differ by 56Γ—. At one charge per frame the wide-ridge grid settles in 57 frames centre-out and 57 tile-major at 1/8, 3 at 1/2 and 2 at 3/4, and the cumulative charge across all 57 frames is exactly the single unbudgeted pass's 876 β€” asserted at every ceiling and in both orders. stream_descent prints that carry measured; the column it once derived was wrong in both directions at the tightest ceiling β€” 140 frames centre-out and 3 tile-major estimated, against 57 measured in each order β€” so it is gone rather than corrected.

The WASM SIMD axis, measured and inert (P1.5, S3 β€” 2026-10-13)

blake3 and the SDF evaluator compile to WebAssembly SIMD when the module is built with -Ctarget-feature=+simd128, which is exactly the difference between a browser that has SIMD and one that does not. That axis was measured rather than assumed: build the engine for wasm32-wasip1 with +simd128, build it again with -simd128, run each under Node's WASI, and slice the boot block out to the // boot complete marker.

Both modules produce the same 17 lines and the same sha256 (496a4dfadf084698…) as the native build, which means SIMD codegen moves neither the hashed lines - EPOCH_0 91b97a52…, REGIONS, SLOT_TABLE, the re-entered REGION_0 - nor the presentation lines. Combined with the scalar-hash CI leg (the A-10 vectors reproduced under blake3's pure build on all three architectures) and the Safari-on-ARM64 job (which prints the native digest from real WebKit), the codegen surface the four named devices could differ on has been narrowed considerably.

Two honest limits. The comparison is not yet a CI step - it is documented with its exact commands so the next harness addition has a target - and none of this is the user's hardware: the N2600, the RTX 3060 box, the Linux-Mesa machine and the M1 browser itself still have to run tools/s3_device_check.sh.

SIMD-invariance is now checked per architecture in CI (P1.5, S3 - 2026-10-13)

tools/wasm_simd_parity.sh builds the engine for wasm32-wasip1 with +simd128 and, in the single-runner job, with -simd128 too, runs each under Node's WASI, slices the boot block from the header to // boot complete, and hashes it. The job matrix adds one +simd128 leg per OS; each runner already diffs its default module against its own native build, so invariance follows transitively without a second rebuild. Run 38360391644 prints the verdict four times - Linux, Windows, ARM64 macOS and the full check - with the boot block at 17 lines and sha256 496a4dfadf084698… in every configuration.

It is a check, not a printout: exit 1 on disagreement (proven by tampering one digit of a depth and watching it report the line and fail), exit 2 when node or the wasm target is missing. Two bugs were found by running it rather than reading it - a filename that collapsed both variants onto one file (making the "full" check vacuous, and it had already merged once as green), and a Windows step that inherited PowerShell until it asked for shell: bash. Neither the harness nor this axis is gate P1.5's remaining clause: the four named machines still have to run tools/s3_device_check.sh.

x86 codegen level cannot move the boot output or the asset bytes (P1.5, S3, AS2 β€” 2026-10-13)

The four machines in gate P1.5 differ in real instruction sets: the N2600 has no AVX2 and no FMA, while a desktop box on the same list has both. tools/cpu_codegen_parity.sh asks the question without owning the silicon β€” build the engine and the asset compiler at the default target and again at -Ctarget-cpu=x86-64-v3, then compare. Boot digest 3c67db5f51644de6… (the published one) and the compiled knot.ron stream 52b85b4b13e5429a… are identical in both builds: no hashed value, and no serialized asset byte, depends on vector width or FMA contraction. CI runs it as a step in the WASM job (run 38361528310, green).

Why the harness refuses to be vacuous: the first attempt at this check "reported" a mismatch that was really a build failure whose empty output got hashed β€” e3b0c442… is the hash of nothing and it looks like a value. A failed build, an empty boot, or an empty .bin now exits 2 (harness could not run) rather than producing a comparison, and every variant is checked against the baseline build so a run that rebuilt nothing cannot pass. Same lesson the SIMD harness taught a few minutes earlier, where a filename collapsed +simd128 and -simd128 onto the same file and the "three-way" check hashed one module twice β€” and had already merged as green.

Which determinism axes CI checks, and the rules that stop them passing vacuously (2026-10-13)

Axes, all on every push: three architectures native; the whole engine as a wasm32-wasip1 module under Node's WASI on each; the same module in bare V8, in Chromium, and in WebKit/JavaScriptCore driven by real safaridriver on an ARM64 Apple-silicon runner; the hasher on blake3's scalar pure build; the module compiled with WASM SIMD on and off; and native binaries at the default x86 target and at x86-64-v3 (AV2+FMA) β€” the ISA gap between gate P1.5's Celeron and its desktop box. All agree on the 17-line boot block (digest 3c67db5f51644de6…) and on the compiled asset bytes.

Peer-comparison legs need rules that pinned-expectation legs don't: an expected digest cannot be satisfied by an empty run, because the empty hash is a constant that simply fails the pin. So the comparisons now refuse an empty asset or empty module output; two fixtures hashing identically (one stream named twice); the empty-input digest e3b0c442… on either side; and any native reference lacking the boot's closing marker β€” two legs agreeing about truncated output is agreement without a boot. tools/check_as2_gate.sh carries negative cases for each rule, so the guards are demonstrated rather than trusted.

The cautionary tale, recorded because it is the one that matters: a filename built with tr -d '+-' collapsed SIMD-on and SIMD-off onto the same file, so the three-way check hashed one module twice and merged green while structurally unable to fail. Three of the four holes found today were in harness code written that same session. Verify a harness by running it and reading the paths it prints β€” and select CI runs by commit SHA, because gh run list --limit 1 immediately after a push returns the previous commit's run.

The device command gained an opt-in ISA axis (P1.5, S3 β€” 2026-10-13)

tools/s3_device_check.sh --codegen now asks the second question each of gate P1.5's machines can differ on in one pass: not only "does this box reproduce the published roots" but "does its output move when the same source is compiled at a different x86 codegen level" (baseline vs x86-64-v3, i.e. without and with AVX2+FMA β€” the actual gap between the N2600 and the RTX 3060 box). Verified end-to-end on a Windows x86-64 host before shipping: exit 0, WASI leg agreeing line for line, and this machine's output does not move across x86 codegen levels. --help prints usage, an unknown option exits 2 rather than being ignored, and the default run is unchanged β€” the flag is opt-in because a second full release build lands hardest on the slowest machine the gate is asking anyone to boot, and a harness that wastes a Celeron's afternoon gets skipped instead of run.

A self-correction worth recording: while wiring this up I nearly added a CI step running a probe that is already in CI twice over. Checking before pushing caught it; the fix was a revert, not a merge conflict.

What the GPU spike measured in CI, and why R1 still cannot be measured there (S9, R1, ADR-026)

PR #1's adapter probe and WGSL-agreement steps ran on all three CI architectures (run 38272893103). The result is the reason gate R1 is not a code gap:

runner adapters visible device opened? what that can and cannot prove
ARM64 macOS Apple Paravirtual device, Metal, IntegratedGpu yes WGSL runs and agrees with the CPU reference; a paravirtual GPU is not a frame-budget measurement
x86-64 Windows Microsoft Basic Render Driver, Dx12, type Cpu (only adapter) yes (software) same: validates the shader, cannot time a frame
x86-64 Linux none no the step prints No device means no frame curve: gate R1 cannot be measured here and skips, by design

With a device, the A-07 WGSL master interpreter matched crates/render on all thirteen implemented kinds within 0.0001 world units (widest twist, 0.000001; 729 sample points per kind) - the contract's "two implementations, one answer" claim, now executed rather than asserted, and labelled presentation-only: not P1.5, not R4, not R1.

S9's cost half, measured on a Windows x86-64 host before the PR: third-party packages 34 -> 104 (66 new), cold build 132 s, target/debug 1,160 MB -> 1,904 MB. What it settles is that the abstraction's price is known and that cargo-deny rejects exactly one licence in it - hexf-parse, CC0-1.0, via naga, arriving through the SPIR-V/DXIL backend paths so no feature trim removes it for a desktop build. The frame curve S9 was finally asked to produce needs a real GPU: hardware, then OQ-51.

P1.6's repair half landed, and its walk cost is measured (2026-10-13)

ledger::repair::catch_up settles the one divergence shape that needs no causal rule: a peer whose records are a verbatim subset of the leader's. It returns the leader's root, and refuses every other case β€” Disagrees names the voxel whose payloads clash, NotBehind refuses the wrong direction instead of pretending to merge, NoRoom refuses to truncate into a state neither peer holds. Choosing a winner when both sides really disagree stays OQ-46, deferred with multiplayer by ADR-004; this function deliberately does not guess.

Both halves of the gate now run in the shipped binary β€” step 3b of cargo run -p engine diverges a peer missing one of three appends, asserts the bisection walk names that voxel (None on the side that never got it), reconciles it to the leader's root, and re-runs the walk to show nothing remains. Detection had never been demonstrated there before; the boot is where a wrong answer becomes visible.

The cost note in the source used to be a rationale ("up to 8 subtree hashes per level per side… the measurement belongs with the spike"). It is now a measurement (cargo run --release --example p16_walk_cost -p ledger, digest counts, not microseconds β€” timings do not repeat across machines): finding a divergence costs 1.26Γ— to 2.23Γ— the two root passes a caller runs anyway to notice one at all β€” 372 digests against 167 at 8 records, 46,903 against 37,287 at 2,048 β€” and the ratio falls as the region grows, because the walk's extra work is one path down twelve levels while the root pass touches every leaf. Agreeing sides cost the two passes only; a lag costs no walk. CI reproduces the numbers so the quote cannot rot.

OQ-51's spike re-measured against current main (2026-10-13)

The wgpu spike branch is the thing standing between this project and gates R1/R2, and its evidence was 25 commits and a day old. It was re-measured in a local scratch merge β€” the branch itself was left untouched, nothing pushed to the PR β€” with these results:

what was checked result on the merged tree
build clean
cargo test --workspace 199 tests, zero failures β€” trunk's 194 plus the branch's own five in crates/gpu/tests/wgsl_validates.rs, so this is the merged tree's count, not a new trunk baseline (trunk is still 194 in thirty suites)
cargo clippy --all-targets --all-features -- -D warnings clean
WGSL ↔ CPU reference, all 13 implemented kinds within 0.0001 world units, widest twist at 0.000001 (Microsoft Basic Render Driver, Dx12)
the CC0-1.0 licence chain unchanged β€” hexf-parse ← naga ← wgpu-core/wgpu, still not feature-trimmable
merging main into the branch one conflict, in .github/workflows/ci.yml only, additive on both sides; union resolution = 63 steps. A chore, not a decision
dependency cost, comparable yardstick main's -p engine resolves 19 third-party packages; the spike's -p gpu resolves 75

Two honesty notes kept in the record: the PR's original 34 β†’ 104 was a workspace-wide count measured by a different method than the 19/75 above, so the two must not be compared; and the part that decides this gate β€” cargo-deny's licence and advisory verdict β€” could not be run locally, because the binary isn't installed here, which is exactly why it belongs to CI on a real merge rather than to anyone's assurances.

A caveat written fifteen minutes earlier ("not re-measured against the current tree") was falsified by doing the measurement, and was replaced rather than left in place.

The device command gained a browser leg (P1.5, S3 β€” 2026-10-13)

Gate P1.5's fourth named target is an M1 browser, and tools/s3_device_check.sh β€” the one command built for real hardware β€” reached only the native and WASI legs. The capability was never missing: CI has driven Chromium-family (browser-execute) and Safari/JavaScriptCore (browser-jsc) for a week. What was missing was a command for the person holding the machine, so --browser now delegates to the same harnesses and compares each engine against that machine's own boot output, not the published digest, so a stale docs/evidence.json cannot manufacture a false agreement or a false failure.

tools/browser_parity_check.sh runs Safari via safari_parity.sh wherever safaridriver exists and a Chromium-family binary wherever one does, and prints its own inventory line β€” SUMMARY: browser legs: safari=… chromium=… β€” because the way this project has repeatedly closed a gate by accident is a leg that quietly did not run. Exit 2 when no engine could be driven at all, which is explicitly not a pass.

The first run produced a fabricated finding, and the harness was changed for it. The leg originally used whatever module sat on disk, so it compared a fresh native boot (db71f8a0…) against bytes left from an older commit (7c71da7c…) and reported CHROMIUM LEG DISAGREES β€” identical in shape to the P1.5 result it exists to catch. It now rebuilds wasm32-wasip1 by default (MLWS_SKIP_BUILD=1 opts out for someone deliberately testing a fixed artifact) and prints the module's sha256 and byte count so the record says which bytes ran.

Re-run on the same Windows x86-64 host: module 3b841513d6f3a6cc…, 284,779 bytes, Chrome reported on the first attempt, browser digest equal to the native boot and to docs/evidence.json, exit 0. The Safari leg reports SKIPPED(not-macos) here, which is the honest answer on this machine and the reason the inventory line exists.

CI now runs the same script (repo 1581db3, run 38400699273, all jobs green). The browser-execute job's inline Chromium step was a second copy of the same comparison β€” the fork rule this project states about marchers applies to harnesses too β€” so the job calls MLWS_ENGINES=chromium MLWS_SKIP_BUILD=1 MLWS_NATIVE_OUTPUT=host.txt bash tools/browser_parity_check.sh and keeps the two guards the script leaves to its caller: the closing-marker check and the declared-vs-measured comparison against docs/evidence.json. A leg returning status 2 prints a loud SKIP rather than reddening trunk, because a runner without a browser is a fact about the image, not about the floats. Read from the logs rather than from the green badge, all three runners drove a real browser: db71f8a0bf5818d6… from Chrome on Windows (890086d4f784ff67… module), on ARM64 macOS (4b05122c8819297f…) and on x86-64 Linux (214a2a7d25ee91e8…) β€” the module hashes differ per platform because each runner builds its own, and the digest the boot prints does not.

Gate P1.5's device clause became data the build checks (P1.5, S3 β€” 2026-10-13)

The demo's third clause β€” one epoch root bit-identical across the four Bitwise Determinism Suite targets β€” is the one this project cannot close from a workstation, and it was also the only one nothing enforced. Its state lived in prose, which can say not met long after the last row stopped being true, and can say met one commit before anyone notices that nobody booted a machine.

docs/evidence.json now carries gate_p1_5_targets: the four named machines, the command that exercises each, their status. All four read "not run". tools/check_docs.mjs holds the rules, and each was proven by violating it rather than by reading it:

violation planted result
an "agrees" row citing a boot digest from an older commit FAILED β€” evidence about one code state cannot close a gate about another
an "agrees" row with no digest FAILED β€” a verdict that names no bytes
a "diverges" row with no note FAILED β€” the most valuable record the register can hold
docs/11 stating a count the register does not hold FAILED
README.md stating a count the register does not hold FAILED
a target quietly dropped from the register FAILED β€” four named targets, or the clause shrank
everything restored OK: no findings

The derived count is written into the prose wherever the gate's status appears, so the sentence a reader sees is checked against the data: currently 0 of 4 named targets have reported. s3_device_check.sh --target <label> prints the paste-ready record for one row and exits 2 on an unknown label β€” the first version printed its refusal after the verdict line and still returned 0, which is a green run that produced no artifact. The record carries host and os beside the verdict, because which machine a label names is a claim the harness cannot verify: the record this workstation printed says M1-browser, host Ian-Dell, and is honest about being neither.

The checker is checked too (6d43db4, CI 38403614341). Those rules are all rules about absence β€” a row with no digest, a count nobody verified, a target that stopped being listed β€” and an absence rule is satisfied just as happily by a checker that stopped working. tools/check_docs_selftest.mjs copies the tree to a temp directory, plants each of the ten violations in turn, requires the validator to report every one, then requires the restored copy to report nothing (ten "must fail" cases would also be passed by a checker that always fails). Measured in CI's docs job, read from its log rather than from its badge: baseline clean, caught: ten times, OK: all 10 planted violations are caught. And the suite was itself falsified β€” with the stale-digest rule sabotaged to accept anything it prints NOT CAUGHT and exits 1.

The published test count is now recomputed by CI, not re-cited (P1.5 honesty β€” 2026-10-13)

"194 tests in 30 suites" appears on README's front page, in docs/11's status table and in docs/evidence.json, and the only thing that checked it was that those three agreed. Three claims checked against claims is the failure mode this project has now hit twice β€” once where CI's browser leg caught a digest every local cross-check had waved through.

The gates job now pipes its own cargo test --workspace into tools/check_suite_size.sh, which sums the per-suite test result: ok. lines and compares the tally with the manifest: exit 1 on disagreement, exit 2 when nothing was measured β€” empty input, a tally that parses to zero tests, or a run containing failures, since counting only the passing lines would understate and pass. The step declares shell: bash, because on the Windows runner the default would swallow the pipe.

Read from the three runners' logs rather than from the badge: each prints RESULT: the published test count is what this run measured - 194 tests in 30 suites. Falsified the same way as every other check added today: deleting three suites from a saved tally produces MISMATCH: the run measures 161 tests in 27 suites while the docs publish 194 tests in 30 suites and exit 1. The parser's own first draft read the word passed instead of the number before it β€” caught by running it once, which is the only way anything in this section has ever been caught.

The written frame is now tied to the march that made it (R5, P1.7 β€” 2026-10-13)

raster_probe renders a 320Γ—240 grid and hands the pixel buffer to a PPM in the temp directory β€” the demo's one viewable artifact. It printed the path and the size and asserted nothing about the file, so the bytes a person would open were never compared with the frame the marcher produced.

Now the example reads the file back and requires every pixel byte to equal the frame in memory, then requires the depth buffer's surface count to equal the march's own stats.hits. Measured, and printed by CI's own log on the Linux runner: all 230400 bytes equal the frame in memory, 51444 of 76800 rays found surface, matching the march's own count.

The statistic's own first draft is the lesson here: it counted pixels that are not pure black and returned 76,800 of 76,800, because the sky is a gradient β€” a number that cannot disagree with any world. It was caught by reading what it printed, not by checking whether the step was green. When adding a measurement, ask which wrong world would make it change.

The multi-cell world has a viewable artifact now (P1.4, R5 β€” 2026-10-13)

region_pop_in measures a 64Γ—48 grid because those are the demo's own rays and the footprint percentages have to be the demo's β€” but a two-cell composite is not legible at 64 pixels wide. The example now re-marches the identical scene at 256Γ—192 and writes it as a PPM, under the same rule raster_probe follows: read the file back, require every byte to equal the frame in memory, and require the surface count to equal what that march reported. The camera is consumed after the measurement loop, so no tabulated figure moved.

All three CI architectures print the identical line β€” 256x192, all 147456 bytes equal the frame in memory, 1720 of 49152 rays found surface β€” which is presentation-byte agreement across vendors. It is informational: the objective's remaining clause is about hashed state on the four named devices, and this is neither hashed state nor those devices.

The boot writes its own three-cell frame on request (P1.7 demo β€” 2026-10-13)

cargo run --release -p engine -- --frame-ppm hands step 9's world_frame β€” the frame the shipped binary already asserts equal to the descent's picture β€” to a 256Γ—192 PPM, nearest-neighbour upscaled from the marched 64Γ—48 because two cells meeting is not legible at that size. It is the boot's own path rather than an example, so nobody has to trust a second copy of the demo's geometry to see what the demo drew. Verified by reading the file back and asking it to agree with the march: 259 of 3072 rays found surface, every byte equal to the frame in memory β€” the same 259 the region line beside it prints. Opt-in, not a second march, and the default boot still hashes to the published db71f8a0bf5818d6…, so nothing anyone cites moved. The flag is filtered out of the asset-path argument, or engine --frame-ppm would try to load a file named after an option.

How it red-trunked, and the lesson is old: the first version was green on every local check β€” build, tests, clippy β€” and failed clippy 1.99 on all three gates architectures for a constant-size chunks_exact(3) where the newer lint demands as_chunks::<3>().0. This laptop's clippy has no such lint. Fixed in a1948a0, trunk green again. For lints, CI's clippy is ahead of yours; local green is not trunk green, and this has now cost a red trunk twice with the same shape.

The lint lag has a cause, a procedure and a question (P1.5 honesty β€” 2026-10-13)

The cause is not an agent's stale machine: rust-toolchain.toml asks for channel = "stable", which is not a version. CI resolves whatever stable its runner image carries β€” 1.99 as of today β€” and a workstation resolves its own (1.92 here). A newer clippy is a superset of lints, and with -D warnings that gap is a build failure nobody can reproduce locally. Both of today's red trunks came from that mechanism.

Two responses, deliberately split by who owns them:

  • Procedure (shipped): tools/local_gate.sh runs the trunk checks in CI's order β€” fmt --check, clippy --all-targets -D warnings, cargo test --workspace, docs integrity, the docs-checker's own self-test, and the AS2 gate logic β€” stops at the first failure, and prints the local rustc and clippy against what the most recent CI run reported. Measured on this workstation: six steps ok on 1.92/clippy 0.1.92, and where the CI version cannot be fetched the line says so instead of implying agreement. If the two version lines differ, CI's lint set is larger than the one that just passed.
  • Question (filed as OQ-62): pin an exact stable in the toolchain file, keep stable and live with the lag, or pin in CI alone. Pinning interacts with the manifest's rust-version = "1.92", so it is a toolchain story decision rather than a commit an agent should make quietly. The register now holds 62 open questions.

A device row cannot claim a leg it never drove (P1.5, S3 β€” 2026-10-13)

Two commits closed the register's last soft shape. tools/check_docs.mjs now requires an "agrees" row to record which legs ran, requires those legs to include native β€” a machine that never computed the roots has not reported anything β€” and requires the M1-browser row specifically to include browser. The hole is the ordinary one: Safari's remote automation is off by default, the leg reports that as a skip, the run carries on and prints verdict: AGREES (correctly: the machine did reproduce every published vector), while nothing reading the register could tell that the engine the target names never ran.

s3_device_check.sh --target <label> now inspects the legs it is about to emit and says so beside the record, remedy included, so the operator meets the refusal on the device rather than in CI three files away. Both paths exercised on this workstation: browser leg unable to run (MLWS_ENGINES=safari on Windows) gives legs: "native,wasi" plus the warning; Chrome driving gives legs: "native,wasi,browser" and no warning. Trunk green at 50438b0.

The self-test grew with the rules β€” 13 planted violations, all caught, pristine control clean β€” and the older no-commit case gained an honest legs value in the same edit, because the legs rule fires first and a planted case that trips a different rule proves nothing about the rule it names.

The local gate learned the check CI used to own alone (P1.5 honesty β€” 2026-10-13)

tools/check_boot_digest.sh runs the boot, hashes its 18 lines and compares the result with docs/evidence.json's published digest. It exists because of one specific incident: a citation edit that landed inside the boot's own printed line changed the whole-output digest while no hashed vector moved, so the local suite β€” build, clippy, 194 tests, cross-file consistency β€” stayed green and only the CI leg that hashes a real run caught it. A claim verified only against other claims is the hole; this is the check that reaches into it before a push rather than after.

tools/local_gate.sh is now seven steps: fmt --check, clippy --all-targets -D warnings, cargo test --workspace, docs integrity, the docs checker's own self-test, the AS2 gate logic, and the boot digest. Measured three ways on this workstation: the honest run exits 0 at db71f8a0bf5818d6…; a manifest pointing at the superseded 7c71da7c… exits 1; a manifest with no claim to read exits 2. A check that cannot measure is never allowed to report a pass.

Preflight, a reconciled exit table, and OQ-50 drafted for approval (P1.5, S3 β€” 2026-10-13)

s3_device_check.sh --preflight answers "what can this machine actually check?" in 1.7 s without building: cargo and rustc versions and host, the wasm32-wasip1 target, node, a browser binary, safaridriver on macOS, the sha256 tool, and which of doc 03's four vectors it found β€” exiting 2 when even the native leg is impossible. The N2600 is the slowest box the gate names and the release build is the expensive part of the command, so discovering afterwards that node is missing spends a compile to learn the legs were skipped anyway. Its own output also surfaced a defect: the header line a device run is asked to paste re-prefixed every field of rustc -vV, duplicating the host line and emitting rustc: binary: rustc. Now it prints version and host. The MISSING branches have not been exercised β€” this machine has every tool β€” so a bare box is their first real test, and that is said rather than implied.

docs/11's Phase 1 exit table was a day behind its own work. Four rows now state what is measured: P1.5 as 0 of 4 named devices reported (a checked register rather than a paragraph), P1.7's frame-visibility blocker as measured rather than open, the scene row as two viewable artifacts CI compares byte-for-byte against the march, and a heading dated for the reconciliation. Citations were re-derived from git log, because the first draft credited --frame-ppm to two commits that were not it.

OQ-50's ruling is now a paste, not a drafting task. docs/13 carries the proposed doc-02 row: a voxel names the half-open span [x, x+1) x [y, y+1) x [z, z+1), centre at +0.5, one cite for renderer, doc 07 collision and future base terrain (ADR-021). It is written as a section under the register β€” giving a proposal its own question id is the exact mistake OQ-61 was opened over β€” and it is applied nowhere, because the ruling is the Architect's and no code converts a voxel to world space yet, so no subsystem has voted.

One command records a device run β€” and it found a latent break in the checker's own tests (P1.5, S3 β€” 2026-10-13)

tools/record_device_run.sh takes the JSON line a device printed and does the bookkeeping in one place: the label must be a real register row, an agrees record must carry the current boot digest, a hex commit, a date and legs including native (plus browser for M1-browser), the derived N of 4 named targets have reported count moves in docs/11 and README in the same step, docs integrity gets the last word, and a failing validator restores all three files. It is a dry run unless --apply.

Two bugs came out of running it rather than reading it. Its first version read the node verdict after stripping the OK| prefix, so the field names shifted one slot and it proposed bumping the prose to 4 of 4 for a single agreeing row β€” the restore path caught it before anything was left behind, which is the behaviour it was written to have. The second was worse and independent of it: two of the checker's fourteen self-test cases hardcoded the counts 0 and 1, so the moment a device genuinely reported 1 of 4 the violation they plant would have been the truth, and CI would have failed on exactly the day the gate advanced. Both cases now derive their numbers from the register under test β€” proven green against a 0-of-4 register and a mutated 1-of-4 one, 13 planted violations caught either way.

The recorder's refusals are now tested where they can be seen failing (P1.5 β€” 2026-10-13)

tools/record_device_run_selftest.sh runs as a step in the docs job and exercises the recorder inside a throwaway copy of the repository with its own git history β€” so the restore path is real and the working tree is never at stake. Four bad records must be refused with the three governed files untouched (a digest other than the published one, an M1-browser row with no browser leg, a label that is not a register row, malformed JSON); one good record must land in all three with docs integrity still passing; a dry run must change nothing. Seven behaviours, all observed in the runner's log at 6830cde.

Two defects worth the telling. The recorder wrote the validator's log into target/, which does not exist on a fresh clone β€” the redirect would have failed and the restore branch would have fired on a valid record; it now uses a temp path. And three bugs were in the new test itself, each found only by running it: --apply placed outside a command substitution became a bare command, and a whole-tree git status check counted the test's own log files as evidence of a dirty tree.

docs/12 now names the verification tools, and the traps they exist for (AS2, P1.5 β€” 2026-10-13)

The operations doc described a loop that ends in "green" and listed none of the machinery, so a new agent would relearn every lesson the hard way. It has a Verifying before you claim section now: tools/local_gate.sh (seven steps in CI's order, ending in the boot's whole-output digest against docs/evidence.json), the docs checker and its 13-violation self-test, s3_device_check.sh --preflight then --browser --target <label>, record_device_run.sh with its own refusal test, and the browser-parity script that CI and a device operator now share deliberately.

Seven traps, each tied to something that actually happened here rather than to taste: a check you have not falsified is not a check; stable is not a pin and cost two red trunks on 2026-10-13 when CI's newer clippy rejected lints the local one never saw; a verification command placed behind a pipe reports the pipe's exit status (three times in one day, once with the wrong interpreter as well); a green run does not prove the step ran, since a leg can pass by skipping, on one runner of three, or against an empty artifact; a harness must rebuild or hash what it compares, because a stale module once fabricated a gate finding; CI runs are selected by headSha and their status starts at queued; and these files are CRLF, which in a scripted edit nearly deleted a register row and left a bash parser blaming the wrong character.

A flake tested the cross-machine guard for free (AS2, ADR-025 β€” 2026-10-13)

frame_cost and pop_in publish figures the docs quote β€” the steps Γ— nodes frame-cost law, and the single-tree 25.1% centre-out against 1.4% tile-major β€” and neither ran in any CI step. Both were run locally first (exit 0, values matching the quotes) and then wired in as informational steps at 438ffb16.

That push went red on asset bytes on ARM64 macOS and on asset bytes agree across machines (AS2) β€” jobs the commit does not touch. Re-running the failed jobs came back all green on attempt 2, so it was a runner flake. Two things follow. A red trunk you did not cause is still yours to resolve: re-run, confirm, then report β€” reverting a correct change to silence an infrastructure failure would have reopened the coverage gap and buried the cause. And the cascade was itself informative: with one of three legs missing, the AS2 job refused to conclude that the machines agree instead of quietly comparing the two that ran. That is the fail-closed behaviour the vacuous-green rules were written for, observed for free by a flaky runner.

The ISA axis now spans both families the four machines cover (P1.5, S3, ADR-017 β€” 2026-10-13)

cpu_codegen_parity.sh asked one question of one architecture: it skipped every non-x86 host, so the axis that separates the N2600 (no AVX2, no FMA) from the desktop box had no counterpart for the machine gate P1.5's fourth target actually is. It picks its spread by architecture now β€” x86-64 baseline against x86-64-v3, arm64 baseline against -Ctarget-cpu=native, i.e. the generic ARMv8 build versus this runner's full NEON feature set β€” and skips anything else with a stated reason. --print shows what a host would be compared without building, which is how both branches were checked on a machine that is only one of them.

A step runs it on the ARM64 leg; read from that job's log rather than its badge:

RESULT: on arm64, boot output and compiled asset bytes are identical across 1 codegen levels [native] and the default baseline β€” hashed lines and asset serialization alike.

What it does not do is close the clause. An Apple CI runner is a proxy for the user's M1 MacBook, not that machine, and the device register still reads 0 of 4: codegen breadth is a determinism question about compilers, the open item is a determinism question about four pieces of hardware nobody has booted this on.

A device verdict must name a commit that exists (P1.5, S3 β€” 2026-10-13)

The register checked that a record's commit looked like a hex SHA and never that it resolved to a commit. It is the field the whole chain hangs on β€” how a verdict on a machine gets tied back to the code state that machine built β€” so a mistyped or invented SHA satisfied the rule while making the claim impossible to recheck. Recording now refuses a commit the clone cannot resolve and says what to do about it: fetch it, or record from the checkout the run was built in.

Deliberately not enforced in CI: actions/checkout defaults to a depth-1 fetch, where a legitimate older ancestor SHA genuinely does not resolve. The check belongs where the history exists β€” the operator's clone, which is also where the register is written.

The self-test gained the case, and its accepting records switched to the throwaway clone's real HEAD, because a test that invents its own SHAs would soon fail for the right reason. Eight behaviours, all verified in the docs job's own log on 403d773.

OQ-51 decided and landed: wgpu is on main (OQ-51, R1, S6, S9, ADR-026 β€” 2026-10-13)

The merge chain is worth reading as a sequence of checks catching each other, because none of it was found by looking:

what broke what caught it
the register and prose still said 194 tests / 30 suites all three gates jobs went red on the CI-recomputed count β€” the local gate has no such step, so a green pre-push run coexisted with a red trunk
ignore = [], β€” a trailing comma from a line-joining patch cargo-deny refused to parse the policy at all: an unreadable policy enforces nothing, and it says so
[advisories] unmaintained = ["paste"] cargo-deny 0.20 rejected the key outright (it does not exist)
ignore = ["paste"] accepted, then read as a yanked-crate waiver: warned that nothing matched, and still failed on RUSTSEC-2024-0436
the real waiver the id taken from the tool's own output, not guessed; a waiver written from an invented identifier would be fabricating the evidence it exists to record
merged files not rustfmt-clean the pre-push gate refused the push before it happened

Final state: advisories ok / licenses ok / bans ok / sources ok, all jobs green at 4797142. Cost, measured: 66 extra crates, one licence allowance, one advisory waiver, and the WGSL-vs-CPU agreement step now runs on every push. What it did not buy is an R1 number. buildbox has a working GTX 1070 β€” driver 580.178.04, Vulkan 1.4 visible to vulkaninfo, after also needing ian added to the render group β€” yet wgpu enumerates zero adapters there, with enabled_backend_features = VULKAN | GL and no loader output even under VK_LOADER_DEBUG=all set correctly inside sg render: it never reaches libvulkan. That is an ash/dlopen-level question, still open. And this session's laptop turned out to be a GeForce MX250, below docs/04's "mid-range dGPU" bar, so its numbers are labelled, not quoted as the gate's answer.

The first GPU measurement, and it is a design verdict rather than a pass (R1, R2, S4, ADR-012 β€” 2026-10-13)

crates/gpu/examples/frame_curve.rs measures the one quantity that can run on a device today β€” the A-07 WGSL master interpreter at one field evaluation per pixel on a three-node tree β€” and times it two ways, because the two costs are not interchangeable: hand-off to the queue, and completion including the copy-back a harness needs to look at a result.

device 1080p submit-only 1080p with read-back
GeForce MX250, Vulkan (this bench) 0.137 ms 91.35 ms
Apple Paravirtual, Metal (CI's ARM64 runner) β€” 54.97 ms

Against R1's 16.67 ms budget for a whole frame, the reading is not "close, needs optimisation" β€” a renderer that computes distances on the GPU and copies them to the CPU every frame cannot meet R1 on this class of hardware whatever the shader does, so the march has to live on the device. That is ADR-012's design restated as a number instead of an intention.

Two guards came out of running it rather than writing it. Interpreter::open() prefers the software adapter on purpose (the agreement check wants identical results across CI architectures and a laptop), which meant the first timing run silently measured the Microsoft Basic Render Driver β€” a CPU β€” so open_hardware() exists, and it retains non-CPU adapters rather than merely ranking CPU last: the Windows runner has nothing else, and would otherwise have reported a software rasteriser as a GPU result. It now prints SKIP frame_curve: no device there and on Linux, benches on macOS, and the whole run is green at 226e8b2.

What the number is not: not a march (~45.5 evaluations per pixel measured on the reference), not culled, not a BVH, not the gate's mid-range dGPU, and never hashed (ADR-017; R4 is informational). R2 was still unmeasured then β€” it needs continuous terrain editing inside a running frame loop, which is the edit-to-geometry step OQ-50 still gated. OQ-50 has since been ruled and both halves of that loop have run; see the two sections below.

buildbox's GPU works now, and the cause was the compiler, not the graphics (R1, S6 β€” 2026-10-14)

Three wrong diagnoses preceded the right one, and each was exposed by running something rather than by reasoning further: wgpu-hal was blamed for declining silently after grepping its lib.rs β€” where it indeed logs nothing β€” while vulkan/adapter.rs warns freely; the binary under test was a stale build with no logger wired in, so "the loader is never reached" described that build rather than the library; driver, ICD, loader layers and /dev/dri permissions were each eliminated on the way.

The actual cause: that host's gcc is a Nix store wrapper, and binaries linked by it don't consult the system ld.so.cache β€” so dlopen("libvulkan.so.1") and dlopen("libEGL.so") fail there while vulkaninfo, linked by the distro toolchain, works fine. Fixed by linking with /usr/bin/gcc-13, recorded in that machine's ~/.cargo/config.toml and not in the repository: one box's toolchain is not a project requirement.

What came out the other side reframes R1 rather than confirming a guess. On a GTX 1070, one A-07 field evaluation per pixel across a full 1080p grid completes in 13.46 ms β€” inside R1's 16.67 ms budget β€” while the ~46 evaluations a real march needs cost 551.5 ms. So the gate is not a shader-speed question that tuning closes; the budget buys roughly one evaluation per pixel and culling, the hierarchy and the step budget have to cover a ~33Γ— gap. The laptop's absolute figures are labelled unstable β€” 91 ms and 627 ms for the same pass minutes apart β€” which is why R1's eventual verification needs a distribution, not a headline number, and why timings stay outside the hashed surface entirely (ADR-017, R4 informational).

An accepted edit now reaches the frame β€” P1.7's missing leg, built and measured (OQ-50, P1.7, ADR-021 β€” 2026-10-14)

cargo run -p engine --example visible_edit walks the whole path for one edit: append to the slab, commit, read the record back, resolve it to a GlobalVoxelPos with voxel_at, derive its centre from doc 02's ruling ([x, x+1), so corner + 0.5), and march a frame with and without it. This is the first consumer of that convention and the only place in the workspace that turns a voxel coordinate into a world position β€” voxel_at stays pure index arithmetic, so nothing pre-decided the question before it was ruled.

The claim is arithmetic, not adjectives: at 8 units the centre ray's surface moves 9.000 β†’ 7.513 where the span predicts 9.000 β†’ 7.500, and 32 of 3,072 pixels shift beyond the march's tolerance. At 512 units zero pixels do, because one world unit spans 0.062 of the frame β€” OQ-56's angular-size fact turned into a guard: a probe that did light up at that range would be the bug, so the assertion points the other way. Three caveats are printed rather than buried: 3,040 of the changed pixels at 8 units are sub-tolerance step jitter from a larger root enclosure, not visible surface; A-07's box takes no centre parameter, so the voxel is drawn as its inscribed ball and a true cube still waits on OQ-55's instance node; and nothing here is hashed, so no P1.5 or R4 claim attaches (ADR-017).

Gate R2's first measured edit loop β€” and the assertable quantity turned out to be an integer (R2, P1.7 β€” 2026-10-14)

cargo run --release --example r2_edit_loop -p engine (now an informational CI step) fills a real Slab to 1 / 100 / 1,000 / 6,000 records β€” 6,000 being ten minutes at ten edits a second β€” and re-marches the same 64Γ—48 frame at each stage, asserting P1.7's placement on every marched frame. The gated quantity is Stats::evaluations, not a duration: 18,284 charged evaluations per frame at every occupancy, asserted equal, printed identically by the Linux, Windows and ARM64 macOS runners. Two instrument lessons came from running the falsification rather than reasoning about it. A plant can be inert: raising max_steps from 96 to 189 changed the charge by nothing and exited 0, because every ray in this frame converges long before the cap β€” the cap is not a term the frame pays β€” while coupling the stop tolerance to occupancy (one part in 1e7 per record) did fire, on a 0.02% change (18,284 β†’ 18,280). And the count first fired for the wrong reason: 19,712 against 18,284 was the fixture's own edit walk climbing into a second z layer, which genuinely changes the marched scene, so the walk is held in one plane and the comparison is about occupancy alone. The milliseconds are printed and asserted nowhere β€” the first draft's 1.3Γ— stage-median bar failed at 2.50Γ— purely on machine load, and two runs of identical code put the 6,000-record median at 3.44 ms and 8.69 ms.

R2 on a device: the shader march, asserted on counts (R2, P1.7, ADR-012, OQ-55 β€” 2026-10-14)

cargo run --release --example device_edit_loop -p gpu is the same loop over the same slab and the same deltas() read-back, with the rays handed to interp.wgsl's march_rays instead of render::raster, measured on a GeForce MX250 over Vulkan. A device hands back fewer assertable terms, so the two that are integers or geometry carry the claim: 3,072 rays find surface at 1 / 100 / 1,000 and 6,000 records, asserted equal across a 6,000Γ— change in occupancy, and at every one of those occupancies the device agrees with the reference ray-for-ray β€” 0 hit/miss disagreements, worst depth deviation under 0.002, the anchor on_device_march uses, which is what keeps a dispatch the driver folded away from arriving here as a frame. The centre pixel returns 7.5129 at every stage, where doc 02's placement says 7.500 and the same grid over unedited terrain says 9.0000 β€” so an accepted edit arrives in a device frame at the distance the ruling gives it. That assert is live: marching the unedited tree inside the loop fired it at occupancy 1 and exited 101.

Two limits are stated in the output rather than buried. The flat hit count is a guard, not the edit's signature: the terrain plane is 2048 units wide, so all 3,072 rays find surface with or without an edit, which is why the visible-edit claim rides on the depth instead. And durations behave even worse on the device than on the CPU β€” two runs of identical code put the stage medians at 3.679–3.900 ms and then 7.222–20.559 ms β€” so nothing timing-shaped is asserted anywhere in the loop (ADR-017). What stays open is structural: the edit reaches the device by a host-side tree rebuild, because A-07 has no instance/translate node in which to place a voxel (OQ-55). R2 is therefore measured on a renderer that keeps the ray loop on the GPU and is still not met as a frame β€” R1's 16.67 ms at 1080p is unmet, and the ten-minute / 36,000-frame span is derived from a flat count, not run.

AS4's hot-path clause: a compiled shape uploaded once, bound per frame (AS4, ADR-015, S4 β€” 2026-10-14)

Interpreter::march had always re-uploaded the node records and the parameter pool β€” one upload, one dispatch, one copy back, which is exactly what a correctness probe wants and exactly what a running frame must not do. upload + march_uploaded now split the two halves (crates/gpu/src/device.rs), with march being their composition so no existing caller changed behavior. cargo run --release --example as4_upload_reuse -p gpu (a CI step) asserts the two terms a device can honestly report: the six reused frames match the per-frame path bit for bit on all 3,072 depths plus the hit count, and the cost term is a byte count β€” 80 shape bytes resident once against 98,304 ray bytes every frame. The equivalence is falsified inside the binary by uploading a shape with one parameter moved (radius 2.0 against 3.5), whose depths are required to diverge β€” they do, first at ray 0, 14.856176 against 14.850224 β€” because "they match" would otherwise also be satisfied by a bind that ignored the buffers entirely.

The finding outruns the clause it closes: the shape is never the per-frame cost, the rays are β€” 80 bytes against 66,355,200 at 1080p β€” and a real renderer generates rays on the device and uploads neither. Consistent with that, the durations were indistinguishable (11.123 ms reused against 11.662 ms per-frame upload) and, per ADR-017, no duration is asserted anywhere.

R2's ten minutes are run, not derived (2026-10-14, repo 20ab7ed)

cargo run --release --example r2_ten_minute_span -p engine (a CI step) marches 36,000 consecutive frames of the 64x48 reference march while one terrain edit is appended to a real Slab every sixth frame β€” ten edits a second at sixty frames, both numbers taken from the gate's own wording, not from tuning. Occupancy therefore grows continuously rather than in the four stages r2_edit_loop sampled, and the invariant is asserted on every frame: 18,284 charged evaluations on all 36,000, compared against frame 1's value at the moment each frame is marched, plus doc 02's placement under the eye, plus the slab handing back exactly the records it took. So doc 03's O(1) hot-path claim now has the span R2 names behind it.

Two runs of the loop's own falsification, and one failure worth more than a pass. Planting a second z layer from record 3,000 fired the charge assert at frame 18,001 (19,712 against 18,284); marching the unedited tree after frame 20,000 fired it at 20,001 (16,184). A third plant was built to be charge-neutral so it would reach the placement assert alone β€” shift the centre one voxel along x β€” and it exited 0: it moved terrain and camera together, and the march is camera-relative, so it changed nothing about the frame. The placement assert is therefore recorded as not independently proven in this instrument (every violation tried also moves the charge, and the charge check runs first), while the same depth check is falsified in device_edit_loop, where saturated hits leave it the only live term.

No duration is asserted, and the reason is now measured three times over: three runs of this code put the median at 1.94 / 2.40 / 3.54 ms and the p99.9 at 8.51 / 14.27 / 108.03 ms β€” one code's tail ranging 12.7x between runs. What remains open is unchanged and named: this is the CPU reference at 64x48, the device loop still samples four occupancies, R1's 16.67 ms at 1080p is unmet, and an edit still reaches the GPU by a host-side tree rebuild because A-07 has no instance node (OQ-55).

The CI leg reproduces the integer and not the clock (run 38621355849). All three gates jobs print 18284 evaluations per frame, asserted equal on all 36000, while their timing columns disagree by an order of magnitude: median 0.68 ms on x86-64 Linux (p99.9 0.86 ms, a 1.27x tail), 0.69 ms on ARM64 macOS (2.39, 3.47x), 0.97 ms on x86-64 Windows (1.68, 1.73x) β€” against 1.94-3.54 ms medians and an 8.51-108.03 ms tail on the busiest laptop. Same code, same 36,000 frames, four machines: the count travels, the tail belongs to the box.

⚠️ **GitHub.com Fallback** ⚠️