diff --git a/PROGRESS-amendment-20-keyed-draws.md b/PROGRESS-amendment-20-keyed-draws.md new file mode 100644 index 000000000..a68f1c915 --- /dev/null +++ b/PROGRESS-amendment-20-keyed-draws.md @@ -0,0 +1,165 @@ +# Amendment 20 — keyed draw streams (lane journal) + +Branch: `amend-keyed-seed-and-uniform-draws`, cut from `origin/main` at `3094bfe84`. +Worktree: `~/PolicyEngine/_worktrees/microcosm-amend-keyed`. No push, no new branches. +Platform of record for H1 pins: **arm64/darwin/py3.14** (Python 3.14.4) — the +fixture's authoring platform. + +*Journal, not state: accurate as written on 2026-09-11. Check git and the +tracking issue for what is true later.* + +Full receipts — every identity, command and exit code, and the three open +decisions with their evidence — are in +[`experiments/amendment-20-keyed-draws-receipts.md`](experiments/amendment-20-keyed-draws-receipts.md). + +## State + +Implementation, tests, pins, charter and changelog are landed and committed. +`packages/microcosm-graph/tests` and `packages/microcosm-fit/tests` are green. + +> Historicized 2026-09-12: the paragraph below described the branch as the lane +> left it. The seed digests were re-pinned when the branch was stacked on +> amendment 19 and pushed as PR #912, so nothing here is red any more; the +> `-0.0` question in item 7.3 of the receipts was settled by normalising +> signed zero in `_coordinate`. + +**The branch cannot go green in CI as it stands**, and that is deliberate: the +`qrf.py` edit moves the spec-engine seed digests, leaving seven tests red in a +lane that runs on every PR. The lane brief said report the drift, not re-pin +it, so the branch carries it and the decision goes to the merge owner — see +**Open for decision** item 1, which carries a tested recipe. + +## Done + +1. `graph/kernel.py`: `SeedSource.KEYED` plus the `KernelContext.rng` docstring. + `ArtifactValue` / `KernelContext.artifacts` deliberately NOT brought over. +2. `graph/randomness.py` (new, 69 lines): `keyed_uniform`, taken verbatim from + `origin/microcosm-us-launch-integration-20260909`. +3. `graph/__init__.py`: `keyed_uniform` exported, inserted in sorted position + among the lowercase callables (`graph_to_json`, `keyed_uniform`, `load_source`). +4. `fit/qrf.py`: `_draw_target_from_uniforms` + `predict_from_uniforms`, taken + verbatim from the same branch (`6e3907f86`); the diff applied with no conflict. +5. `docs/graph-interface.lock`: `kernel.py` re-recorded + `eaf07da2… → 3483d091b03b19ae35c0268c01cb9e0f76c4cd63742083130567321d70da6048`. + The lock is plain `shasum -a 256` of the file bytes (confirmed against the + unchanged `decl.py` line). +6. Tests: `test_graph_randomness.py` (new), `SeedSource.KEYED` contracts in + `test_graph_kernel_contract.py`, `test_qrf_stateless.py` (new, verbatim from + the integration branch), a `QRFKernel` non-widening guard in microcosm-fit's + `test_kernels.py`, and `test_graph_parity_pins.py` (new). +7. `tools/graph_parity_repin.py` (new) + the re-pinned `fit.qrf` fixture. +8. `docs/graph-acceptance.md` amendment 20 + a `changelog.d` fragment. + +**No `test_acceptance_*.py` file was edited at all**, so the "acceptance-suite +edit is its own commit" rule never had to be exercised. + +### Second pass, after an adversarial review of the branch (same day) + +Six independent reviewers read the working tree; each finding was then put to +two adversarial verifiers. Four findings survived and were fixed: + +9. **The re-pin now checks the environment it derives foreign keys from** + (`test_graph_parity_repin.py`, new, 8 tests). The guard added in the first + pass reproduced every pinned key from `pins["implementation_hash"]` — which + substitutes away the only input carrying dependency versions, so *any* + environment reproduced the pins and the check could not see the drift it + existed to catch. `pins.json` already recorded the versions the keys were + taken under; nothing read them. `repin` now refuses unless they equal this + machine's installed versions, before deriving anything. +10. **The re-pin docstring no longer claims what the code does not do.** It had + said the reproduction loop "proves the assumption rather than asserting it" + and that "only one locked environment can do that". Neither was true. It now + states what each of the two checks establishes. +11. **The final-bin closure is pinned.** Amendment 20 claims two deliberate + differences from the generator path; only the strict comparison was tested. + Deleting `cumulative[:, -1] = 1.0` left all fourteen stateless tests green. +12. **The keyed-kernel contract test asserts the half it only asserted in + prose.** Its toy body never referenced `context.rng`, so "a keyed kernel + simply does not spend it" held by construction. It now deep-copies the bit + generator state across the call; adding `context.rng.random(1)` to the body + makes it fail. + +Each fix was mutation-tested: the mutation that breaks the behaviour makes the +new assertion, and only it, go red. + +## Key findings (verified this session) + +- The integration branch carries **no** executor change for `KEYED` and **no** + `fit/kernels.py` change. `grep` over `origin/main`'s `executor.py` finds no + `seed_source` branch. Honouring `KEYED` therefore required no executor edit. +- `QRFKernel.implementation_hash()` hashes `microcosm.fit.qrf`'s module bytes, + so editing `qrf.py` moved `fit.qrf@1`'s implementation hash + (`02db8f5c… → d1f8b192…`) and all three pinned platform node keys. +- **Foreign-platform node keys are derivable here because the environments + agree — not because the fingerprint is the only channel.** A platform reaches + a key two ways: the fingerprint string, and the implementation hash, into + which `source_hash` folds `f"{distribution}=={version}"` for every declared + dependency. Re-deriving the three OLD pinned keys with the OLD implementation + hash substituted and only the fingerprint varied reproduces all three — which + establishes that each pin is the key its platform computed *under that hash*, + and establishes nothing about the dependency channel, because substituting + the hash is exactly what removes it. That channel is now checked separately + and explicitly. (The first pass wrote "so the platform reaches a key as that + string and nothing else"; the experiment could not support it.) +- **`tools/graph_parity_fixtures.py` must not be edited.** `ParityCsvSource` + and `ParityRulesEngine` are defined there, so its bytes are inside + `ParityCsvSource.implementation_hash()` and + `SimulateRulesKernel.implementation_hash()`. Measured: adding the re-pin code + there moved the calibrate node key `184ccd0a → f8c9ed4b` with its + implementation hash unchanged. The re-pin logic therefore lives in a sibling + module. Reverted. +- `generate()` resets `pins["platforms"]` to the local platform alone, so a bare + regeneration would have dropped both `x86_64/linux` pins and silently put H1 + on its off-platform branch there (which asserts no bytes). +- `direct.csv` is byte-identical before and after on every platform + (`7b8dbd56c91ee71552ff6d892a42c56494b1813fb5d4b11553a8a8ccc9b90dca`). +- **`origin/main` moved during the lane** (to `e6d362b7e`, PR #909, object-dtype + storage hashing). It touches `graph/store.py` and `graph/population.py`; + neither enters `QRFKernel.implementation_hash()`. Merging is clean + (`git merge-tree`, no file touched by both), and running H1 parity, the pin + tests and the tolerance pin with main's post-#909 versions of those two files + in place is green, so main's advance does not disturb this lane's pins. + +## Open for decision (Max / the merge owner) + +1. **The spec-engine seed digests are stale on this branch, and that is + merge-blocking.** `tools/spec_engine_coverage.py --check` exits 1 (0 on + `origin/main`). The cause is `packages/microcosm-fit/src/microcosm/fit/qrf.py` + alone, bisected: `microcosm.fit.qrf` is in `_QRF_KERNEL_MODULES` + (`spec_engine/seeds.py:353-362`), whose `source_inventory_sha256` hashes each + module's exact installed source bytes. `SeedSource.KEYED`, `randomness.py` + and the export move none of it. **Seven tests are red**: one failure in + `test_spec_engine_inventory_coverage.py` and six fixture errors in + `test_spec_engine_coverage_tool.py`. Both files run in the **`engine-shared`** + CI lane, which has no `if:` condition — it runs on every PR and the aggregate + gate requires it — so this cannot be merged as it stands. (The integration + branch re-pinned these same two digests itself, in `1734b9e90`; its values + cannot be copied here because they also fold in its own `acs_transfer` and + housing changes.) The lane brief said + report, do not re-pin, so the branch carries the drift. The re-pin is two + pin values plus a regenerated evidence file; it was applied, verified green, + and reverted this session, so the recipe in the lane report is tested rather + than proposed. +2. **Amendment numbering.** `main`'s list ends at 18, so 19 is the next free + number by its own arithmetic. The brief assigns 20 (the artifacts lane owns + 19). Two unmerged commits on the + `candidate-quality-producer-integration-20260905` family already claim both: + `3ff92b0ae` = 19 ("Typed artifacts and stable draw coordinates", which + bundles this lane's subject), `d2043d85e` = 20 ("Failed typed evidence + remains a failed gate"). Neither is on `main` or on the integration branch. + Concrete consequence of leaving the gap: CommonMark renumbers an ordered + list from its first item, so the entry *renders* as 19 however it is + written — colliding with the number the artifacts lane expects. +3. **`keyed_uniform` gives `-0.0` and `0.0` different draws** (verified: + `0.3464688…` vs `0.3251263…`), though they are `==` and hash-equal in Python. + `canonical_json` emits them distinctly and `_coordinate` does not normalise + the sign, so a float coordinate arriving as `-0.0` on one run re-randomises + that row. Not fixed here on purpose: `randomness.py` is byte-identical to the + integration branch that owns it, and `"sha256-u53-v1"` is a *versioned* + normative algorithm — changing which uniform a coordinate draws is a version + bump, not a drive-by fix. For that lane to decide. + +## Next + +- Nothing blocking in this lane's own work. A reviewer should decide (1), (2) + and (3) above; (1) must be resolved before the branch can go green in CI. diff --git a/changelog.d/amend-keyed-seed-and-uniform-draws.added.md b/changelog.d/amend-keyed-seed-and-uniform-draws.added.md new file mode 100644 index 000000000..44b0ecec0 --- /dev/null +++ b/changelog.d/amend-keyed-seed-and-uniform-draws.added.md @@ -0,0 +1 @@ +Graph interface amendment 20, keyed draw streams: `SeedSource.KEYED` declares that a kernel's randomness comes from normative stream parameters (`("sha256-u53-v1", experiment_id, replicate, base_seed)`) and one stable coordinate per draw rather than from a position in the executor's generator, and `microcosm.graph.randomness.keyed_uniform` computes those draws as the top 53 bits of a SHA-256 over the canonical stream and canonically tagged coordinates, reading and advancing no numpy RNG state. Order, chunking, and unrelated inserted identities cannot reach a draw. `FittedRegimeGatedQRF.predict_from_uniforms` is the first consumer surface: it draws from caller-supplied per-row uniforms, so pairing them with stable entity ids makes a batch invariant to recipient ordering and batching, and it advances no model RNG. `fit.qrf@1`'s existing outputs are unchanged — `predict` and its consumption order are untouched and the method is additive, and the kernel still declares `PARAM` or `EXECUTOR` — but its implementation hash moves with its module's source, so the H1 `fit.qrf` parity pins are re-recorded on all three pinned platforms while every `direct.csv` stays byte-identical. Adding an enum member rather than a normative field gains no node a projection field, so the member itself moves no node key — every kernel keeps projecting the seed source it already declared, and the `fit.qrf` keys move because its implementation hash moved. `tools/graph_parity_repin.py` re-pins one parity case in place, keeping every platform it carries instead of dropping the ones the local machine cannot run; it writes a key for a platform it is not only after refusing unless this environment's dependency versions are the ones the pins were taken under and every pinned key reproduces from the implementation hash recorded beside it. diff --git a/changelog.d/keyed-draw-input-validation.fixed.md b/changelog.d/keyed-draw-input-validation.fixed.md new file mode 100644 index 000000000..fc473f4f2 --- /dev/null +++ b/changelog.d/keyed-draw-input-validation.fixed.md @@ -0,0 +1,3 @@ +Reject temporal random coordinates before NumPy scalar conversion and reject +non-real QRF uniform arrays before float conversion. Refuse inconsistent +authoring keys when re-pinning graph parity fixtures. diff --git a/docs/evidence/spec-engine/us-f0-coverage.json b/docs/evidence/spec-engine/us-f0-coverage.json index 45e73dcbb..a759198bc 100644 --- a/docs/evidence/spec-engine/us-f0-coverage.json +++ b/docs/evidence/spec-engine/us-f0-coverage.json @@ -1656,13 +1656,13 @@ "compiler_ir.node_slices" ], "expected": { - "map_sha256": "87ba50531d9fa6683096ecb39a31655b331ab8acff3a5876c2f62b33562a0885", - "protocol_sha256": "fd3e4b06f11be4e8c13ea19fef9469ab95cbbe3e2dcfce351e860dd3e00709e4" + "map_sha256": "20058e544f6034cee2e76d3b864cf86b6230c8dce042931dfe9247d8ac1329c4", + "protocol_sha256": "553d5e0bd5afa93146dfbf8dca684926ee773dbfc89a559434528af5b4eb1d97" }, "failures": [], "observed": { - "map_sha256": "87ba50531d9fa6683096ecb39a31655b331ab8acff3a5876c2f62b33562a0885", - "protocol_sha256": "fd3e4b06f11be4e8c13ea19fef9469ab95cbbe3e2dcfce351e860dd3e00709e4" + "map_sha256": "20058e544f6034cee2e76d3b864cf86b6230c8dce042931dfe9247d8ac1329c4", + "protocol_sha256": "553d5e0bd5afa93146dfbf8dca684926ee773dbfc89a559434528af5b4eb1d97" }, "status": "covered" }, @@ -1677,7 +1677,7 @@ "compiler_ir.seed_stream_map" ], "expected": { - "implementation_sha256": "fd3e4b06f11be4e8c13ea19fef9469ab95cbbe3e2dcfce351e860dd3e00709e4", + "implementation_sha256": "553d5e0bd5afa93146dfbf8dca684926ee773dbfc89a559434528af5b4eb1d97", "protocol": "legacy-v1", "streams": [ "build_model", @@ -1698,7 +1698,7 @@ }, "failures": [], "observed": { - "implementation_sha256": "fd3e4b06f11be4e8c13ea19fef9469ab95cbbe3e2dcfce351e860dd3e00709e4", + "implementation_sha256": "553d5e0bd5afa93146dfbf8dca684926ee773dbfc89a559434528af5b4eb1d97", "protocol": "legacy-v1", "streams": [ "build_model", @@ -2599,7 +2599,7 @@ "country": "us", "schema_id": "country_spec", "schema_version": 1, - "spec_sha256": "35a02b6b19c921faba1407d441e0b9d9623c496e2cd5b711be014def281a95c6" + "spec_sha256": "1eeca53aa80da949a292fbd8cb0afefde95c68888ed35f3f477f3c962e6bc644" } }, "report_schema_version": 3, @@ -2609,7 +2609,7 @@ "country": "us", "schema_id": "country_spec", "schema_version": 1, - "spec_sha256": "35a02b6b19c921faba1407d441e0b9d9623c496e2cd5b711be014def281a95c6" + "spec_sha256": "1eeca53aa80da949a292fbd8cb0afefde95c68888ed35f3f477f3c962e6bc644" }, "status": "pass" } diff --git a/docs/graph-acceptance.md b/docs/graph-acceptance.md index 71ef7fffb..64b59bfde 100644 --- a/docs/graph-acceptance.md +++ b/docs/graph-acceptance.md @@ -377,6 +377,42 @@ Amendments so far (each re-locked): Raised by the US launch integration branch (`microcosm-us-launch-integration-20260909`), which extended both frozen files without an amendment; extracted and adopted 2026-09-11. +20. **Keyed draw streams.** `SeedSource.KEYED` and + `microcosm.graph.randomness.keyed_uniform`: a keyed kernel's draws are a + pure function of normative stream parameters — `("sha256-u53-v1", + experiment_id, replicate, base_seed)` — and one stable coordinate per + draw, conventionally `(person_id, process, period, draw_index)`, with a + float coordinate's signed zero normalised to one identity, as the + capabilities projection already does for tolerances. It + reads and advances no generator, so a row's draw stops depending on how + many rows were drawn before it: the invariance to packing that C1 and C2 + already gave a node's key and seed now reaches each individual draw, and + an inserted or removed identity leaves every other row's value alone. C4 + is neither weakened nor edited — `randomness.py` consumes no RNG at all, + positionally or otherwise, so its static check still holds over the + whole shard. `microcosm-fit` carries the first consumer surface, + `FittedRegimeGatedQRF.predict_from_uniforms`, which draws from + caller-supplied per-row uniforms; pairing those with stable entity ids + makes a batch's results invariant to recipient ordering and chunking. + **`fit.qrf@1`'s existing outputs are unchanged.** `predict`, its RNG + consumption order, and every value it draws are untouched; the new + method is additive, and the kernel still declares `PARAM` or `EXECUTOR`, + never `KEYED`. Its *implementation* identity moves all the same, because + `QRFKernel.implementation_hash()` hashes `microcosm.fit.qrf`'s module + bytes — so H1's `fit.qrf` pins are re-recorded on every pinned platform + while `direct.csv` stays byte-identical on each, and that byte-identity + is the evidence for the additivity claim rather than a restatement of + it. Unlike amendments 11 and 13 this adds an enum member, not a + normative field: no node's canonical projection gains a field, so the + member itself moves no node key — every kernel keeps projecting the seed + source it already declared. The `fit.qrf` keys move because the + implementation hash moved, not because `KEYED` exists. One deliberate + difference from the generator path, commented where it lives: the sign + gate's inverse CDF compares strictly and closes its final bin at 1.0, + so a uniform of exactly zero skips a zero-probability class instead of + selecting it, and a CDF that sums to just under one can no longer + silently select the first class. Raised by the US launch integration + branch, which carried the code without an amendment; adopted 2026-09-11. Adding a normative field with a default changes the canonical projection of every node that carries it, so node keys moved with amendments 11 and diff --git a/docs/graph-interface.lock b/docs/graph-interface.lock index 6d6d4eceb..b9b01741c 100644 --- a/docs/graph-interface.lock +++ b/docs/graph-interface.lock @@ -1,2 +1,2 @@ ed0a859adcae12510d5ba74d51c694617201f7b448b108a3f602410f5da44876 decl.py -97c3ec9fc38aacb58f99e69c5a913ffca72c897cea4ca1002659ef06198b2b33 kernel.py +dbf57c137330f0f12744c557ee594586a1b308b6d6adaba1938e2b6efded21ca kernel.py diff --git a/experiments/amendment-20-input-validation-20260912.md b/experiments/amendment-20-input-validation-20260912.md new file mode 100644 index 000000000..83ef0d66d --- /dev/null +++ b/experiments/amendment-20-input-validation-20260912.md @@ -0,0 +1,41 @@ +# Amendment 20 input validation review + +On 12 September, an independent executed Astra review of PR912 head +`4e8a1048606b6943acad5b556a2915b846c7a389` found two input-validation defects +and one parity-tool inconsistency. This follow-up repairs all three. + +- NumPy temporal scalars are refused before `.item()` can erase their type + and time unit. Supported boolean, integer, real floating and Unicode scalar + coordinates retain their existing tags and draws. +- QRF supplied uniforms must have real numeric dtypes before conversion to + float64. Complex arrays, including those with non-finite imaginary parts, + cannot silently lose information before the range/finiteness checks. +- The parity re-pin tool refuses a top-level authoring key that disagrees + with the corresponding platform entry before deriving or writing any key. + +The 17 new parameter cases gave 13 failures and four passes against the old +implementation, then all passed after repair. The complete graph and fit +suites passed 624 tests with no failures/errors/skips in 162.91 seconds. +The six spec/identity targets passed 62 tests with no failures/errors/skips +in 235.17 seconds. Ruff and changed-file formatting passed. + +QRF's source bytes contribute to its implementation identity and the seed +protocol. Its three platform parity keys were re-recorded with the guarded +tool; every `direct.csv` remains unchanged. The US/AM/BE/UK spec envelopes, +minimal-loader golden, protocol and seed-map pins were recomputed from the +real loader/compiler. Coverage regeneration passed all 42,156 configuration +fields and 41 inventory checks. No unrelated inventory pins, frozen graph +declarations, interface lock, or acceptance tests changed. + +These are software-contract checks. They do not certify a dataset, native +replay, calibration, release or publication. PR912 remains subject to the +corrected-head independent review and CI merge gate. + +Fable's next review of `6391b809` found that two existing parity-tool tests +selected the authoring platform as their foreign entry on Linux. Their +intentional corruption then reached the new authoring-consistency refusal +before the foreign-key reproduction check those tests meant to exercise. +Both now choose an entry that is neither local nor the authoring platform. +All nine parity-tool tests and Ruff pass locally on macOS; the exact-head +Linux CI runs must establish the corresponding Linux result. This correction +changes only test setup and this record, with no implementation or pin change. diff --git a/experiments/amendment-20-keyed-draws-receipts.md b/experiments/amendment-20-keyed-draws-receipts.md new file mode 100644 index 000000000..a26400575 --- /dev/null +++ b/experiments/amendment-20-keyed-draws-receipts.md @@ -0,0 +1,551 @@ +# Build lane report — amendment 20, keyed draw streams + +Repo: `PolicyEngine/microcosm`. Branch: `amend-keyed-seed-and-uniform-draws`, +cut from `origin/main` at `3094bfe84`. Worktree: +`~/PolicyEngine/_worktrees/microcosm-amend-keyed`. Nothing pushed; no new +branches; the stash was never touched; `uv.lock` unchanged. + +**Platform string of record for every H1 measurement below: +`arm64/darwin/py3.14`** (Python 3.14.4) — the `fit.qrf` fixture's own authoring +platform, so the local pin is *produced* rather than derived. + +**Three things need your decision, in §7.** One of them is merge-blocking: +the branch cannot go green in CI as it stands, by design of the brief. + +> Historicized 2026-09-12: the decisions in §7 were taken when the branch +> was stacked on amendment 19 and pushed as PR #912 — see the notes under +> §5, §7.1, §7.2, §7.3 and §8. The report below is the lane's record, not +> the branch's state. + +This report is also committed at +`experiments/amendment-20-keyed-draws-receipts.md`, because the root `out.md` +is a tracked file holding a different lane's report — see §7.4. + +--- + +## 1. What landed + +Sixteen commits. Red-before-green wherever behaviour changed. + +**First pass — the amendment itself:** + +``` +b46955507 Start the amendment 20 lane journal (keyed draw streams) +8d55f8172 Red: keyed draw streams and SeedSource.KEYED have no implementation yet +e75bd6b91 Green: SeedSource.KEYED and keyed_uniform; relock kernel.py +2c4dc34fd Red: FittedRegimeGatedQRF has no predict_from_uniforms yet +82e11113e Green: QRF draws from caller-supplied per-row uniforms +80c41e081 Add a re-pin path that keeps every pinned H1 platform, and check them all +c96a3f412 Re-pin the fit.qrf H1 fixture; direct.csv is byte-identical +77b7daabe Amendment 20: keyed draw streams; changelog fragment +2d0681c8f Bring the lane journal up to the landed state +``` + +**Second pass — after an adversarial review of the branch (§6):** + +``` +457b36944 Red: the re-pin derives foreign keys without checking the environment +8b284c584 Green: a re-pin checks the environment it derives foreign keys from +12637772a Pin the final-bin closure the amendment claims +3beca70a5 Make the keyed-kernel contract test assert the half it only asserted in prose +6ea291779 Assert the authoring pin against the fixture, not against the helper +8fb973941 Say precisely which node keys amendment 20 moves, and why +74abdbbaf Bring the lane journal up to the reviewed state +``` + +`git diff --stat $(git merge-base HEAD origin/main)...HEAD` — 16 files, +**+1495 / −6**: + +| File | ± | What | +|---|---|---| +| `packages/microcosm-graph/src/microcosm/graph/kernel.py` | +4 −2 | `SeedSource.KEYED = "keyed"` and the `KernelContext.rng` docstring. **Nothing else** — no `ArtifactValue`, no `ArtifactType` import, no `KernelContext.artifacts`, no `__all__` entry for them. | +| `packages/microcosm-graph/src/microcosm/graph/randomness.py` | +69 | New. `keyed_uniform` + `_coordinate`, **byte-identical** to the integration branch's copy (sha256 `797fa7e4c7cc2fc81312f7886521e9b0f3e02be577e601a9cabc24362485847a`; `diff` empty). | +| `packages/microcosm-graph/src/microcosm/graph/__init__.py` | +2 | `keyed_uniform` imported and placed in `__all__`'s lowercase tail between `graph_to_json` and `load_source`. | +| `packages/microcosm-fit/src/microcosm/fit/qrf.py` | **+84 −0** | `_draw_target_from_uniforms` and `predict_from_uniforms`. Blob-identical to `6e3907f86` (`e8bb2c95611c1d650dfc78785ac90f7325cc3f75`). The `−0` is the mechanical form of the additivity claim: no existing line is touched. | +| `docs/graph-interface.lock` | +1 −1 | `kernel.py` re-recorded. | +| `docs/graph-acceptance.md` | +35 | Amendment 20. | +| `changelog.d/amend-keyed-seed-and-uniform-draws.added.md` | +1 | towncrier fragment. | +| `packages/microcosm-graph/tests/test_graph_randomness.py` | +205 | New, 12 tests. | +| `packages/microcosm-graph/tests/test_graph_kernel_contract.py` | +98 −8 | 3 new `SeedSource.KEYED` contract tests, one of them strengthened in the second pass. | +| `packages/microcosm-fit/tests/test_qrf_stateless.py` | +182 | New. 14 tests verbatim from the integration branch, plus the final-bin-closure test the second pass added. | +| `packages/microcosm-fit/tests/test_kernels.py` | +16 | One guard: `SeedSource.KEYED` must not widen `QRFKernel`. | +| `packages/microcosm-graph/tests/test_graph_parity_pins.py` | +116 | New, 11 tests — every pinned H1 platform key is checkable from every platform. | +| `packages/microcosm-graph/tests/test_graph_parity_repin.py` | +204 | New, 8 tests — the re-pin tool's refusals (second pass). | +| `tools/graph_parity_repin.py` | +328 | New. The re-pin path. | +| `packages/…/fixtures/parity/kernels/fit.qrf/pins.json` | +1 −1 | Re-pinned. | +| `PROGRESS-amendment-20-keyed-draws.md` | +151 | Lane journal, committed from the first commit. | + +**`tools/graph_parity_fixtures.py` is NOT touched** — §4 explains why that +matters. **No `test_acceptance_*.py` file is touched at all**, so the +"acceptance-suite edit is its own commit" rule never had to be exercised. + +--- + +## 2. What was deliberately NOT brought over + +- **`ArtifactValue` / `KernelContext.artifacts`** — a separate lane owns them. + Verified absent: neither `git diff origin/main...HEAD` nor the working-tree + diff contains any occurrence of `ArtifactValue`, `ArtifactType`, or + `artifacts=` in code. The integration branch's `__post_init__`, its + `__init__.py` exports of `ArtifactInput`/`ArtifactOutput`/`ArtifactType`/ + `ArtifactValue`/`SourceBytesCodec`/`load_source_bytes`, and its + artifacts-dependent new module `fit/qrf_target.py` are all absent. +- **Any `executor.py` change.** The brief allowed "the minimal coherent + subset". The measured answer is **nothing**: + - The integration branch's executor diff is +419 lines of typed artifacts, + lazy population retention and receipt metadata, and contains **no + occurrence of `SeedSource`, `KEYED`, or `keyed`**. + - `grep -n "seed_source"` over `origin/main`'s `executor.py` returns nothing. + The executor never branches on the seed source; it always builds + `KernelContext.rng` from the node key and lets the kernel decide whether to + spend it. A `KEYED` kernel simply does not. + - Every other `SeedSource` use site in the repo was checked for a member + enumeration that would reject `"keyed"`; none rejects it. The only site + where a new member changes accept/reject behaviour is the capability + projection, which the contract tests cover. +- **Any `microcosm-fit/kernels.py` change.** The integration branch's diff + there is **empty**: it wires no `KEYED` draws into `fit.qrf@1`. + `QRFKernel.__init__` still refuses anything but `PARAM`/`EXECUTOR`, and this + branch adds a test asserting it keeps refusing. + +--- + +## 3. H1 parity — the pins, and the `direct.csv` byte-identity proof + +### 3.1 Why the pins had to move + +`QRFKernel.implementation_hash()` is +`source_hash(type(self), fit_qrf, fit_model_module, qrf_module, dependencies=…)`, +and `source_hash` digests each object's **defining module's bytes** plus +`f"{distribution}=={version}"` for each declared dependency (read this session +at `graph/kernel.py:387-427`). `qrf_module` is `microcosm.fit.qrf`. So an +additive edit to `qrf.py` moves the implementation hash, and `node_key` folds +`kernel_impl_hash` in — for a `PLATFORM_BITWISE` kernel alongside the platform +fingerprint. All four pinned identities move. + +``` +implementation_hash 02db8f5c849d876be20a95152b5302a5cacc0a7c77c58d8b436a3a00f57b4c92 + → d1f8b1929e6452aa507b0d9c64ba42a59851dc31c71021303c25f060e34c075b + +node keys OLD NEW +arm64/darwin/py3.14 8878352d… → 35af6a452bd32ca39d313d78255236df01877d684c2d847b1bd5a6016a68e237 (produced) +x86_64/linux/py3.13 f6984280… → 6c43edcb3edf2bdef20d915b1be16503d37583c678aced78f1442aca1139e1ae (derived) +x86_64/linux/py3.14 9e80ee3a… → ecb6b20c02b0bc442c4baf3fd7def6d6aec06e6b9567910294f945d66c43bbf8 (derived) +``` + +### 3.2 The `direct.csv` byte-identity proof + +`direct.csv` did **not** change, on any of the three platforms. Three +independent ways: + +1. **Content digests, before and after.** All three files, unchanged: + + ``` + 7b8dbd56c91ee71552ff6d892a42c56494b1813fb5d4b11553a8a8ccc9b90dca fit.qrf/direct.csv + 7b8dbd56c91ee71552ff6d892a42c56494b1813fb5d4b11553a8a8ccc9b90dca fit.qrf/platforms/x86_64-linux-py3_13/direct.csv + 7b8dbd56c91ee71552ff6d892a42c56494b1813fb5d4b11553a8a8ccc9b90dca fit.qrf/platforms/x86_64-linux-py3_14/direct.csv + ``` + (These three were already byte-equal to each other on `main`; this fixture's + twelve recipient draws happen to agree across the pinned platforms.) + +2. **Git.** `git diff --numstat` over the fixture tree shows `1 1 + …/fit.qrf/pins.json` and **no other fixture file**. Re-verified this + session: after running the re-pin on all three cases, `git status + --porcelain packages/microcosm-graph/tests/fixtures` is empty. + +3. **The tool refuses otherwise.** `repin()` recomputes the direct call from the + builder and compares it to the stored bytes *before* writing anything; a + byte difference raises `SystemExit`. The re-pin succeeded, so that comparison + passed — and `test_a_moved_direct_call_refuses_rather_than_re_pinning` now + pins that the refusal actually fires. + + `predict()` also still draws the same values under test: + `test_stateless_replays_legacy_uniforms_across_all_regimes` replays + `predict()` against an independently-advanced generator and asserts exact + frame equality across all seven regimes. + +### 3.3 What licenses the two derived foreign keys — corrected + +A node key is a pure function of the declaration, the resolved input +identities, the implementation hash, the capability projection, and — for a +platform-bitwise kernel — the platform fingerprint **string**. + +The first pass claimed the reproduction of the three old pins established that +"the platform reaches a key as that string **and nothing else**". **That +inference was wrong, and the review caught it.** A platform also reaches its +key through the implementation hash, which folds in that machine's installed +dependency versions; the reproduction experiment substituted the recorded +implementation hash, which is precisely what removes that channel. It could not +be evidence about it. + +What the reproduction does establish is narrower and still useful: each pinned +key is the one its platform computed *under the recorded hash*. The dependency +channel is now checked separately and explicitly — `repin` refuses unless +`pins["dependencies"]` (the versions the existing keys were taken under, already +recorded in the fixture and previously never read) equals this machine's +installed versions. Together the two checks say the pinned platforms and this +one shared one locked environment at pin time, which is the condition that makes +deriving the new foreign keys sound. + +`repin()` also re-derives the local key alongside executing the graph and +refuses if the two disagree, so the derivation cannot drift from what the +executor computes. + +**What is derived and what is not.** Keys are derived. **Bytes are never +derived**: each platform's `direct.csv` is left exactly as that platform +recorded it. I did not, and cannot, re-measure x86_64 floats from this machine. + +### 3.4 The re-pin is idempotent + +Re-verified this session, after the second pass changed the tool: + +``` +tools/graph_parity_repin.py fit.qrf exit 0, pins.json unchanged +tools/graph_parity_repin.py calibrate exit 0, pins.json unchanged +tools/graph_parity_repin.py simulate exit 0, pins.json unchanged +git status --porcelain empty +``` + +### 3.5 Standing guards + +- `test_graph_parity_pins.py` derives **every** pinned platform's key on + whatever platform is running and compares it to the pin. Before this, a stale + foreign pin survived until that Linux lane happened to run, and H1's + off-platform branch asserts no bytes at all — so a stale x86 pin was invisible + on a Mac. +- `test_graph_parity_repin.py` (second pass) pins each of the tool's refusals, + including the two the review found untested. Mutation-tested: deleting the + dependency check makes exactly the two behavioural tests red. + +### 3.6 `origin/main` moved during the lane — checked, no effect + +`origin/main` advanced from `3094bfe84` to `e6d362b7e` (PR #909, object-dtype +storage hashing) while this lane ran; worktrees share refs, so another session +fetched it. It touches `graph/store.py` and `graph/population.py`. Neither is in +`QRFKernel.implementation_hash()`'s source set, and neither is an attested +seed-kernel module. Measured rather than assumed: + +- `git merge-tree --write-tree HEAD origin/main` → exit 0, no conflict; zero + files touched by both sides. +- With main's post-#909 `store.py` and `population.py` swapped into the tree, + `test_acceptance_h_parity.py` + `test_graph_parity_pins.py` + + `test_fit_qrf_tolerance_source_hash_pin_is_current` → **exit 0**. Tree + restored and verified byte-identical afterwards. + +--- + +## 4. Why `tools/graph_parity_fixtures.py` was not edited (a trap worth recording) + +The first pass's initial attempt added `repin` to that module. It works — and it +silently moved **every parity node key in all three cases**: + +``` +calibrate node key 184ccd0a25e1d00cc4393b2880ff13a601ac04207bc37b0160b7ae60596cb3d7 + → f8c9ed4b33c75f3886471e48beb7c83685ead036e8335735ee9b2334cbbd2b1d +with calibrate.adam@1's implementation hash unchanged. +``` + +Because `ParityCsvSource` and `ParityRulesEngine` are **defined in** that +module, its bytes are inside `ParityCsvSource.implementation_hash()` and +`SimulateRulesKernel.implementation_hash()`. Every parity graph's source node +re-keys when the file changes, and every downstream node with it. + +So the re-pin lives in a sibling module, `tools/graph_parity_repin.py`, which +defines no kernel and imports the generator's `_pins` / `_write_pins` / case +builders so a re-pinned fixture stays indistinguishable from a generated one. + +The second reason not to use `generate()`: it rewrites `pins["platforms"]` as +the local platform alone. Running it here would have dropped both +`x86_64/linux` entries, orphaned their `direct.csv` files, and put CI's Linux +lanes onto H1's off-platform branch (no byte assertion) without any test going +red. + +--- + +## 5. Every command, with its direct exit code + +> Historicized 2026-09-12: the two red rows below (`spec_engine_coverage +> --check`, the two coverage test files) were cleared by the re-pin recorded +> under §7.1; the lock line predates the merge with amendment 19, after which +> `docs/graph-interface.lock` was re-recorded over the merged files. + + +Environment prepared with `uv sync --all-packages --locked --extra us --extra uk`; +everything after used `uv run --no-sync`. + +The review agents mutation-tested by editing source files and reverting them, +so a mid-review run could in principle have measured a mutant. The headline +suite figure was therefore re-measured after every agent had finished, against +a tree verified to carry no `MUTANT` marker and no `.py` differing from `HEAD` +(`packages` tree `112501f5c275182b6bd4ea10769e44bce514d4b9`); it agrees with +the earlier separate runs. + +| Command | Exit | Result | +|---|---|---| +| `pytest packages/microcosm-graph/tests packages/microcosm-fit/tests` | **0** | **487 passed**, 1 warning, 117s — the authoritative run, on the committed tree with nothing else touching it (the warning is a pre-existing pydantic deprecation from `policyengine_uk`) | +| `pytest packages/microcosm-graph/tests` | **0** | **371 passed**, 143s | +| `pytest packages/microcosm-fit/tests` | **0** | **116 passed**, 389s | +| `pytest test_acceptance_h_parity.py test_graph_serialize.py::test_generated_parity_graphs_bind_real_kernels_and_direct_bytes test_graph_executor.py::test_fit_qrf_tolerance_source_hash_pin_is_current` | **0** | 6 passed — the three named H1 checks, on `arm64/darwin/py3.14` | +| `pytest test_graph_parity_repin.py` (new) | **0** | 8 passed | +| `pytest test_graph_parity_repin.py test_graph_parity_pins.py` | **0** | 19 passed | +| `python tools/ci_test_groups.py --verify` | **0** | `verification=ok`. The three new test files land in fast `rest` and engine `us-am`; **none** under `[defaulted]` (that section is 53 pre-existing `microcosm-build` files, and `ci_test_groups.py` is byte-identical to main's) | +| `ruff check .` | **0** | All checks passed | +| `ruff format --check` on every file this branch touches | **0** | all formatted | +| `shasum -a 256 -c docs/graph-interface.lock` | **0** | `decl.py: OK`, `kernel.py: OK` | +| `python tools/graph_parity_repin.py fit.qrf` / `calibrate` / `simulate` | **0** | idempotent; fixtures unchanged | +| `git merge-tree --write-tree HEAD origin/main` | **0** | clean merge with current main | +| **`python tools/spec_engine_coverage.py --check`** | **1** | **drift — §7.1** | +| **`pytest test_spec_engine_inventory_coverage.py test_spec_engine_coverage_tool.py`** | **1** | **1 failed + 6 errors = 7 red — §7.1** | + +Note on `ruff format --check .` repo-wide: it reports 81 files would be +reformatted, but that is **pre-existing on `main`**, and CI runs only +`ruff check .`. Every file this branch touches is format-clean. + +--- + +## 6. The second pass: what an adversarial review of the branch found + +Six reviewers read the working tree along separate axes (charter claims, test +rigor, the re-pin tool, brief compliance, implementation correctness, repo +integration); every finding was then put to two adversarial verifiers whose +default was to refute. 42 agents, no errors. Of eighteen findings, **3 survived +both verifiers, 7 split, 8 were refuted by both**. Adjudicating the splits +myself: four defects were fixed in code (each mutation-tested), two survivors +plus one split are the decisions in §7, and the rest were prose or duplicates +of the same root cause. + +1. **The re-pin tool's central claim was false.** Its docstring said the + reproduction loop "proves the assumption rather than asserting it" and that + "only one locked environment can do that". Neither was true: the loop + substitutes `pins["implementation_hash"]`, which is the only input carrying + dependency versions, so *any* environment reproduces the pins. Probed + directly — with the local kernel hash made uncallable, and with a faked + `scikit-learn` version, the loop still passed. Fixed by making the code do + what the prose claimed (read the recorded dependency versions, which + `pins.json` already carried and nothing read) and by rewriting the prose to + state what each check establishes. +2. **The final-bin closure had no test.** Amendment 20 claims two deliberate + differences from the generator path; only the strict comparison was pinned. + Deleting `cumulative[:, -1] = 1.0` left all fourteen stateless tests green. + The guard is load-bearing — on a `predict_proba` row summing to just under + 1.0, a uniform above the sum makes every comparison false and `argmax` + returns 0, silently selecting the *first*, most negative, class. Now pinned + by a test that is the only one to fail without the closure. +3. **A contract test could not fail for the defect it named.** Its toy body + never referenced `context.rng`, so "a keyed kernel does not spend it" held by + construction. It now asserts the bit generator state across the call; adding + `context.rng.random(1)` makes it fail. Its docstring also claimed the + behaviour was "exercised end to end at the kernel protocol level" — it is + not, because the executor has no `seed_source` branch — and now says so. +4. **A pins test asserted about its own helper.** It read through `_platforms`, + which back-fills the top-level pin, so the assertion that the authoring + platform appears in the mapping was satisfied by the helper rather than by + `pins.json`. + +Findings raised and **refuted** on inspection, recorded so they are not +re-litigated: the `_RELEASED` guard in `_draw_target_from_uniforms` is untested +but mirrors an existing tested one; the amendment's line-wrapping (fixed +anyway); and the "no test covers the re-pin tool" framing (now moot). + +What the review checked and found **clean**: no forbidden artifacts code; +`kernel.py` exactly two hunks with the `KEYED` line character-identical to the +integration branch's; `randomness.py` and `qrf.py` byte/blob-identical to their +sources; the executor conclusion sound on both its grounds; `graph-interface.lock` +digests reproduce and `shasum -c` passes; `__all__` placement correct (that list +is not globally sorted on main, and this does not make it worse); test placement +and CI grouping correct; changelog fragment name and type correct; `uv.lock` +untouched. + +--- + +## 7. Needs your decision + +### 7.1 The spec-engine seed digests are stale — and this is merge-blocking + +> Historicized 2026-09-12: applied in PR #912 — `seed_protocol` and +> `seed_map` re-pinned in `inventory_coverage.py`, the evidence file +> regenerated, and the pool-tool test's `spec_sha256` pin moved with it; +> `spec_engine_coverage.py --check` exits 0 on the pushed head. + + +| | `origin/main` | this branch | +|---|---|---| +| `tools/spec_engine_coverage.py --check` | exit **0** (42156/42156 fields, 41/41 checks) | exit **1** | +| `test_spec_engine_inventory_coverage.py` | pass | **1 failed** | +| `test_spec_engine_coverage_tool.py` | pass | **6 errors** (fixture setup) | + +**Cause, bisected** (first pass, re-confirmed this session by computing the +digests directly): `packages/microcosm-fit/src/microcosm/fit/qrf.py` **alone**. +`microcosm.fit.qrf` is listed in `_QRF_KERNEL_MODULES` +(`spec_engine/seeds.py:353-362`); `source_inventory_sha256` hashes each module's +logical name and **exact installed source bytes**; that is the +`regime_gated_qrf` attestation's `source_sha256`, which is inside +`SeedProtocol.implementation_sha256`, which the inventory compares against +`EXPECTED_HASHES`. `SeedSource.KEYED`, `randomness.py` and the export move none +of it. This is exactly the situation `CLAUDE.md` describes: *"Spec identities … +attest kernel source and locked RNG-library versions, so they legitimately move +when main changes an attested module."* + +``` +seed_protocol pinned fd3e4b06f11be4e8c13ea19fef9469ab95cbbe3e2dcfce351e860dd3e00709e4 + now d8f595a05dd71d7eccce5976d640baf97541e3ca159fd6179e7b1ab1a1286d8e +seed_map pinned 87ba50531d9fa6683096ecb39a31655b331ab8acff3a5876c2f62b33562a0885 + now 373786d5ba5e115c3317c42662b3998cdef8061441cf0ba48c36828bd7d55e69 +``` + +**Why it blocks the merge.** Both test files run in the `engine-shared` CI job, +which — read in `.github/workflows/test.yml:202` — carries **no `if:` +condition**, so it runs on every PR, and line 367 (`require_success +engine-shared`) makes the aggregate gate depend on it. The branch's changes also +classify as `shared` (`packages/*/src/**`, `tools/**`), so `engine-us` runs too. +`gh pr checks` cannot go green as the branch stands. + +**The brief said report, do not re-pin, so the branch carries the drift.** The +fix is two pin values plus a regenerated evidence file (8 lines). I applied it, +measured it, and reverted it this session, so this is tested rather than +proposed: + +```diff +--- a/packages/microcosm-build/src/microcosm/build/spec_engine/inventory_coverage.py +@@ -359,8 +359,8 @@ EXPECTED_HASHES = { +- "seed_map": "87ba50531d9fa6683096ecb39a31655b331ab8acff3a5876c2f62b33562a0885", +- "seed_protocol": "fd3e4b06f11be4e8c13ea19fef9469ab95cbbe3e2dcfce351e860dd3e00709e4", ++ "seed_map": "373786d5ba5e115c3317c42662b3998cdef8061441cf0ba48c36828bd7d55e69", ++ "seed_protocol": "d8f595a05dd71d7eccce5976d640baf97541e3ca159fd6179e7b1ab1a1286d8e", +``` + +then `uv run python tools/spec_engine_coverage.py` (no `--check`) to regenerate +`docs/evidence/spec-engine/us-f0-coverage.json`. Verified afterwards — **all +seven tests, both files**, not just the one the first pass measured: + +``` +tools/spec_engine_coverage.py --check exit 0 +pytest test_spec_engine_inventory_coverage.py test_spec_engine_coverage_tool.py + exit 0 (22 passed) +``` + +Tree restored and digest-verified against its pre-state both times. + +**One fact that should weigh on the decision:** the integration branch that +this lane is landing code from re-pinned *these exact two digests itself*, in +`1734b9e90` ("Re-pin the seed protocol and compiled seed map digests for the +launch integration") — verified as an ancestor of that branch this session. Its +replacement values differ from the ones this branch needs (`c15bff65…` / +`a775cccd…`), because they also fold in that branch's own `acs_transfer` and +housing changes, so they cannot be copied. But it establishes that the upstream +lane treated this re-pin as the required companion to the `qrf.py` change rather +than as an open question. CLAUDE.md's own guidance ("merge main and re-pin") +points the same way. My recommendation is to apply it — on the merge ref, so the +digests attest the tree that actually merges. + +### 7.2 Amendment numbering — and it renders as 19 regardless + +> Historicized 2026-09-12: amendment 19 (typed artifacts) merged first as +> PR #911, so this entry is 20 by main's own arithmetic and renders as 20. + + +`main`'s list ends at **18**, so by its own arithmetic the next free number is +**19**. The entry is numbered **20** because the brief assigns 20 and gives 19 to +the artifacts lane. Two unmerged commits on the +`candidate-quality-producer-integration-20260905` family already claim both: +`3ff92b0ae` = 19 ("Typed artifacts and stable draw coordinates", which *bundles* +this lane's subject), `d2043d85e` = 20. Neither is on `main` or on the +integration branch, and the integration branch's `docs/graph-acceptance.md` +diff against main is empty — it carries the code with no amendment at all, which +is what this lane was sent to fix. + +**New this pass:** leaving the gap has a concrete cost. The amendments are one +contiguous CommonMark ordered list, and renderers renumber from the first item — +so the entry **displays as "19."** on GitHub however it is written, colliding +with the number the artifacts lane expects and contradicting the item's own +text, the changelog fragment, the test docstrings and the journal, all of which +say 20. Nothing executable parses the list (the burndown and `explain.py` read +only `| A1 |`-style table rows), so nothing breaks — but the document already +*reads* as 19. Renumbering to 19, or landing a 19 placeholder, are both +one-token edits; which is right depends on what the artifacts lane lands as. + +### 7.3 `keyed_uniform` draws differently for `-0.0` and `0.0` + +> Historicized 2026-09-12: fixed in PR #912 — `_coordinate` normalises a +> float zero to `0.0` (as `keys._canonical_tolerance_float` already did for +> tolerances), with a contract test and a sentence in the amendment entry. +> No version bump: the algorithm string had not shipped on `main`. + + +Verified directly: + +``` +canonical_json([["float", 0.0]]) -> b'[["float",0.0]]' +canonical_json([["float", -0.0]]) -> b'[["float",-0.0]]' +draw((0.0,)) = 0.3464688222542801 +draw((-0.0,)) = 0.3251263802628821 +0.0 == -0.0: True | hash equal: True | draws equal: False +``` + +`_coordinate` normalises numpy scalars to Python scalars precisely so "a +coordinate read out of a column must not draw differently", but it does not +normalise float signed zero, and `canonical_json` passes floats straight to +`json.dumps`. A float coordinate arriving as `-0.0` on one run and `0.0` on +another — trivially produced by negation or a CSV literal — silently +re-randomises that row. (The int-vs-float distinction, by contrast, is +deliberate and documented: the tags are meant to differ.) I checked for a +documented signed-zero convention in the graph shard and found none. + +**Not fixed here, on purpose.** `randomness.py` is byte-identical to the +integration branch that owns it, and `"sha256-u53-v1"` is a *versioned* +normative algorithm: changing which uniform a coordinate draws under the same +version string is a version bump, not a drive-by fix, and would collide with +that branch on exactly the normative draw algorithm. The one-line change is +`value = value + 0.0` in `_coordinate`'s float branch. For the owning lane to +decide. + +### 7.4 The tracked root `out.md` + +The brief's `-o` path is `out.md`, which is also a **tracked** file on `main` +carrying a different lane's stale report ("F1 portable worker identity — Sol +gate round 1", last written by `71dbe2979`, 2026-09-04). Writing this report +there and committing it would silently delete that record from `main`; leaving +it uncommitted means it dies with the worktree. + +So: this report is written to `out.md` as the brief requires and left +**uncommitted**, and the identical content is **committed** at +`experiments/amendment-20-keyed-draws-receipts.md` — the home `CLAUDE.md` +sanctions for lane receipts. Someone should still decide whether a tracked +`out.md` belongs in the repo at all; it is a collision waiting to happen for +every lane that gets handed the same `-o` path. + +--- + +## 8. Things checked that turned out clean + +> Historicized 2026-09-12: the interface lock is now enforced in the suite +> by `packages/microcosm-graph/tests/test_graph_interface_lock.py` (PR #910). + + +- **No other stale pin.** Repo-wide grep for the old identities `02db8f5c`, + `8878352d`, `f6984280`, `9e80ee3a` across `*.py`, `*.json`, `*.md`, `*.yaml` + and `*.yml` finds no hit in any source file, test or fixture — only in this + report and the lane journal, which quote them deliberately. +- **No existing node key moves.** Adding an enum *member* is not adding a + normative *field*: `_capabilities_projection` serialises + `capabilities.seed_source.value`, so every kernel that declares `EXECUTOR`, + `PARAM` or `NONE` projects exactly what it projected before. The `fit.qrf` + keys move because its implementation hash moved, not because `KEYED` exists — + the charter entry now says this explicitly rather than leaving the reader to + reconcile "no existing node key moves" with a re-pinned fixture in the same + diff. +- **C4's static check still holds.** It ASTs every file under `microcosm/graph/`; + `randomness.py` contains no `RandomState`, no `*.random.seed`, and no + `default_rng` call at all. `test_acceptance_c_seeds.py` is green. +- **`docs/graph-interface.lock` is plain `shasum -a 256` of the file bytes** — + `shasum -a 256 -c` reports both OK. Worth knowing: **nothing in the repo + verifies this lock.** No test, tool, or workflow step reads it; the only + references are prose. That is pre-existing, not a branch defect, but a lock + nothing checks is a lock that will drift. +- **CI grouping needs no bookkeeping.** All three new test files land in fast + `rest` and engine `us-am` automatically; `--verify` is green and none appears + under `[defaulted]`. +- **The `KernelContext` field order contract is untouched** — + `test_context_numerics_default_empty_and_carry_scopes` asserts the last two + dataclass fields are `["tolerances", "numerics"]`, which is why bringing + `artifacts` over would have broken it. It is green. diff --git a/packages/microcosm-build/src/microcosm/build/spec_engine/inventory_coverage.py b/packages/microcosm-build/src/microcosm/build/spec_engine/inventory_coverage.py index 89171055c..ca3c0357b 100644 --- a/packages/microcosm-build/src/microcosm/build/spec_engine/inventory_coverage.py +++ b/packages/microcosm-build/src/microcosm/build/spec_engine/inventory_coverage.py @@ -359,8 +359,8 @@ "late_schedule": "e59c019d3d454eac99ac0ac209b6c5b6faaf9bdfcaeee18c36a25be19bf7da2f", "ownership": "5f64f0aac49e2313177564f71876bffc8c81b3ded4df701e70930e60e9c98356", "primary_tuples": "987b501c695e31f45521c4a178528f75ab3df22c09bc407b182213b2de99ee57", - "seed_map": "87ba50531d9fa6683096ecb39a31655b331ab8acff3a5876c2f62b33562a0885", - "seed_protocol": "fd3e4b06f11be4e8c13ea19fef9469ab95cbbe3e2dcfce351e860dd3e00709e4", + "seed_map": "20058e544f6034cee2e76d3b864cf86b6230c8dce042931dfe9247d8ac1329c4", + "seed_protocol": "553d5e0bd5afa93146dfbf8dca684926ee773dbfc89a559434528af5b4eb1d97", "source_manifest": "cd5ba8924d64da5425ee14cca82a774e3f4b2bb5aabe06df291cc3cc457287a9", "take_up": "fa186daea0f8dd641cc470e41d1a2953f887d45282ec990201298f47bedf8d4d", "tail": "ac92829c88a1a4fb6460d61190918d5d99c6c377fc8dd8f62f02b332d09bf59c", diff --git a/packages/microcosm-build/tests/test_spec_engine_country_bundles.py b/packages/microcosm-build/tests/test_spec_engine_country_bundles.py index a826e90db..fecadaba5 100644 --- a/packages/microcosm-build/tests/test_spec_engine_country_bundles.py +++ b/packages/microcosm-build/tests/test_spec_engine_country_bundles.py @@ -25,7 +25,7 @@ "spine", "vintages", } -AM_SPEC_SHA256 = "d983a5e6bb2f91f9abd44669fe5b1c795d1a7597c4b6acbe9282eaa956bb326d" +AM_SPEC_SHA256 = "c4163f9ab3577cd5b6d509c6fe091d0f95ae7892a4746562a57a62a3f8b3be8e" @pytest.mark.parametrize( @@ -45,7 +45,7 @@ ), ( "be", - "2a7d83483c90abe31108f8c3a77c053b4e6f2fd50d3cf3290a85e63ea3b9fbac", + "fe25bbc48c785801bc7f518380eff9f528a3ceab4fa8501e1b527362c9c579b6", { "household.household_id", "person.person_id", @@ -55,7 +55,7 @@ ), ( "uk", - "c396ee51ab029ed780ec25b69d34b128f53b08c673dc87a7f600f74264e755e3", + "3ea1b53abb0ceef57b6ddef6d49251579e633bfe7d5ae7ec5a2152c0858445af", { "benunit.benunit_id", "household.household_id", diff --git a/packages/microcosm-build/tests/test_spec_engine_loader.py b/packages/microcosm-build/tests/test_spec_engine_loader.py index 0b583de0a..240228a0b 100644 --- a/packages/microcosm-build/tests/test_spec_engine_loader.py +++ b/packages/microcosm-build/tests/test_spec_engine_loader.py @@ -236,7 +236,7 @@ def test_semantic_hash_has_golden_vector_and_surface_separation(tmp_path) -> Non # Pin the domain separator, normalization rules, schema-set receipt, and # exact normative projection as one reviewable golden vector. assert first.spec_sha256 == ( - "57026e5896dd52a382746fe7641f615dcc4c55965a1f50bea5971a8f92710d71" + "b465989064184f03ecc296c13bec8ffc5a70d5dd4cf30d42487b399633d32a62" ) second_root = _rich_minimal(tmp_path / "xy", note="second", store="local:b") diff --git a/packages/microcosm-build/tests/test_us_multispine_pool_tool.py b/packages/microcosm-build/tests/test_us_multispine_pool_tool.py index 019e495d4..683d496d4 100644 --- a/packages/microcosm-build/tests/test_us_multispine_pool_tool.py +++ b/packages/microcosm-build/tests/test_us_multispine_pool_tool.py @@ -2504,7 +2504,7 @@ def capture_equality(expected: object, actual: object) -> None: "country": "us", "schema_id": "country_spec", "schema_version": 1, - "spec_sha256": "35a02b6b19c921faba1407d441e0b9d9623c496e2cd5b711be014def281a95c6", + "spec_sha256": "1eeca53aa80da949a292fbd8cb0afefde95c68888ed35f3f477f3c962e6bc644", }, } diff --git a/packages/microcosm-fit/src/microcosm/fit/qrf.py b/packages/microcosm-fit/src/microcosm/fit/qrf.py index a39c57d2a..13735f5c5 100644 --- a/packages/microcosm-fit/src/microcosm/fit/qrf.py +++ b/packages/microcosm-fit/src/microcosm/fit/qrf.py @@ -1034,6 +1034,36 @@ def _draw_target_with_rng( return values +def _draw_target_from_uniforms( + features: pd.DataFrame, + model: _TargetModel, + quantiles: np.ndarray, + sign_uniforms: np.ndarray, +) -> np.ndarray: + """Evaluate a target without consuming or replacing any model RNG state.""" + if model is _RELEASED: + raise RuntimeError("This target's fitted forests were released; refit to draw.") + if model.regime == Regime.DEGENERATE_ZERO: + return np.zeros(len(features), dtype=np.float64) + if model.regime == Regime.POSITIVE_ONLY: + return model.positive.draw(features, quantiles) + if model.regime == Regime.NEGATIVE_ONLY: + return model.negative.draw(features, quantiles) + x = features.loc[:, list(model.columns)].to_numpy(dtype=np.float64) + cumulative = np.cumsum(model.gate.predict_proba(x), axis=1) + # Uniforms occupy [0, 1): strict comparison skips zero-probability classes + # even at u=0. Close the final CDF bin against floating-point roundoff. + cumulative[:, -1] = 1.0 + chosen = (cumulative > sign_uniforms[:, None]).argmax(axis=1) + signs = np.asarray(model.gate.classes_)[chosen] + values = np.zeros(len(features), dtype=np.float64) + for sign, forest in ((1, model.positive), (-1, model.negative)): + mask = signs == sign + if mask.any() and forest is not None: + values[mask] = forest.draw(features.loc[mask], quantiles[mask]) + return values + + class RegimeGatedQRF: """The canonical :class:`~microcosm.fit.model.ConditionalModel`. @@ -1564,6 +1594,66 @@ def predict( self._target_models[target] = _RELEASED return out + def predict_from_uniforms( + self, + frame_or_df: Frame | pd.DataFrame, + *, + quantiles: Mapping[str, np.ndarray], + sign_uniforms: Mapping[str, np.ndarray], + ) -> pd.DataFrame: + """Draw using caller-supplied per-row uniforms, without advancing RNG. + + Each mapping must contain exactly the fitted targets, with one finite + real numeric one-dimensional array in ``[0, 1)`` per target, aligned + to input rows. Complex, object and string arrays are refused. + Supply both arrays even for single-sign or all-zero targets. Later + targets condition on earlier draws, just as in :meth:`predict`. + + Pairing uniforms with stable entity IDs makes results invariant to + recipient ordering and batching. Fitted forests remain reusable. The + legacy :meth:`predict` stream and its consumption order are unchanged. + """ + features = self._predictor_frame(frame_or_df) + arrays = {} + for name, supplied in ( + ("quantiles", quantiles), + ("sign_uniforms", sign_uniforms), + ): + if not isinstance(supplied, Mapping) or set(supplied) != set(self.targets): + raise ValueError(f"{name} must contain exactly the fitted targets.") + arrays[name] = {} + for target in self.targets: + raw = np.asarray(supplied[target]) + if raw.dtype.kind not in "iuf": + raise ValueError( + f"{name}[{target!r}] uniforms must be real numeric arrays." + ) + values = np.asarray(raw, dtype=np.float64) + if values.shape != (len(features),): + raise ValueError( + f"{name}[{target!r}] must have shape ({len(features)},)." + ) + if ( + not np.isfinite(values).all() + or ((values < 0) | (values >= 1)).any() + ): + raise ValueError(f"{name}[{target!r}] uniforms must be in [0, 1).") + arrays[name][target] = values + out = pd.DataFrame(index=features.index) + if features.empty: + return out.reindex(columns=self.targets).astype(np.float64) + augmented = features.copy() + for target in self.targets: + drawn = _draw_target_from_uniforms( + augmented, + self._target_models[target], + arrays["quantiles"][target], + arrays["sign_uniforms"][target], + ) + out[target] = drawn + augmented[target] = drawn + return out + def _predictor_frame(self, frame_or_df: Frame | pd.DataFrame) -> pd.DataFrame: """Extract the predictor columns from a Frame or DataFrame input.""" if isinstance(frame_or_df, Frame): diff --git a/packages/microcosm-fit/tests/test_kernels.py b/packages/microcosm-fit/tests/test_kernels.py index dba6fc9e5..5307a7364 100644 --- a/packages/microcosm-fit/tests/test_kernels.py +++ b/packages/microcosm-fit/tests/test_kernels.py @@ -244,6 +244,22 @@ def test_capabilities_protocol_and_wrapped_source_hash() -> None: ) +def test_the_graph_kernel_does_not_claim_the_new_keyed_seed_source() -> None: + """Amendment 20 adds ``SeedSource.KEYED``; ``fit.qrf@1`` still declares neither. + + ``predict_from_uniforms`` is an additive method on the fitted model, not a + new binding of the wrapped kernel: the kernel keeps taking its seed from a + literal param or from the executor, so no ``fit.qrf@1`` node changes what it + declares. A widening here would be a silent contract change. + """ + assert QRF_PARAM_KERNEL.capabilities.seed_source is SeedSource.PARAM + assert QRF_EXECUTOR_KERNEL.capabilities.seed_source is SeedSource.EXECUTOR + with pytest.raises(ValueError, match="must be SeedSource.PARAM or"): + QRFKernel(SeedSource.KEYED) + with pytest.raises(ValueError, match="must be SeedSource.PARAM or"): + QRFKernel(SeedSource.NONE) + + @pytest.mark.parametrize( ("kernel", "mutate_params", "match"), [ diff --git a/packages/microcosm-fit/tests/test_qrf_stateless.py b/packages/microcosm-fit/tests/test_qrf_stateless.py new file mode 100644 index 000000000..e697e394a --- /dev/null +++ b/packages/microcosm-fit/tests/test_qrf_stateless.py @@ -0,0 +1,193 @@ +"""Caller-owned uniforms make repeated QRF draws stable by identity.""" + +import copy + +import numpy as np +import pandas as pd +import pytest + +from microcosm.fit import fit +from microcosm.fit.qrf import Regime + + +@pytest.fixture(scope="module") +def model(): + x = np.tile(np.arange(30, dtype=float), 6) + donor = pd.DataFrame( + { + "x": x, + "positive": x + 1, + "negative": -x - 1, + "zero": np.zeros(len(x)), + "mixed": np.tile([-2.0, 0.0, 3.0], len(x) // 3), + "inflated": np.tile([0.0, 4.0], len(x) // 2), + "negative_inflated": np.tile([0.0, -4.0], len(x) // 2), + "two_sign": np.tile([-3.0, 4.0], len(x) // 2), + } + ) + return fit( + donor, + ["x"], + list(donor.columns[1:]), + weights="none", + n_estimators=4, + seed=7, + ) + + +def uniforms(model, n): + return { + "quantiles": {t: np.linspace(0, 0.99, n) for t in model.targets}, + "sign_uniforms": {t: np.linspace(0.99, 0, n) for t in model.targets}, + } + + +def test_stateless_draws_preserve_rng_and_forests(model): + recipient = pd.DataFrame({"x": np.arange(20, dtype=float)}) + before = copy.deepcopy(model._rng.bit_generator.state) + draws = uniforms(model, len(recipient)) + first = model.predict_from_uniforms(recipient, **draws) + pd.testing.assert_frame_equal( + first, model.predict_from_uniforms(recipient, **draws) + ) + assert model._rng.bit_generator.state == before + assert (first.positive > 0).all() + assert (first.negative < 0).all() + assert (first.zero == 0).all() + assert set(first.mixed) == {-2.0, 0.0, 3.0} + assert set(first.inflated) == {0.0, 4.0} + assert set(first.negative_inflated) == {0.0, -4.0} + assert set(first.two_sign) == {-3.0, 4.0} + + +def test_permutation_and_chunking_preserve_chained_draws(model): + recipient = pd.DataFrame({"x": np.arange(20, dtype=float)}) + draws = uniforms(model, len(recipient)) + expected = model.predict_from_uniforms(recipient, **draws) + order = np.random.default_rng(4).permutation(len(recipient)) + reordered = model.predict_from_uniforms( + recipient.iloc[order], + **{k: {t: v[order] for t, v in d.items()} for k, d in draws.items()}, + ) + pd.testing.assert_frame_equal(expected, reordered.sort_index()) + chunks = [] + for rows in (slice(0, 7), slice(7, 20)): + chunks.append( + model.predict_from_uniforms( + recipient.iloc[rows], + **{k: {t: v[rows] for t, v in d.items()} for k, d in draws.items()}, + ) + ) + pd.testing.assert_frame_equal(expected, pd.concat(chunks)) + + +@pytest.mark.parametrize("bad", [[-0.1, 0.5], [0.1, 1.0], [np.nan, 0.1], [0.1]]) +@pytest.mark.parametrize("field", ["quantiles", "sign_uniforms"]) +def test_invalid_uniforms_rejected_before_drawing(model, bad, field): + recipient = pd.DataFrame({"x": [1.0, 2.0]}) + draws = uniforms(model, 2) + draws[field][model.targets[-1]] = np.array(bad) + with pytest.raises(ValueError, match="uniform|shape"): + model.predict_from_uniforms(recipient, **draws) + + +def test_uniform_target_names_must_match(model): + draws = uniforms(model, 2) + del draws["quantiles"][model.targets[-1]] + with pytest.raises(ValueError, match="targets"): + model.predict_from_uniforms(pd.DataFrame({"x": [1.0, 2.0]}), **draws) + + +@pytest.mark.parametrize("field", ["quantiles", "sign_uniforms"]) +@pytest.mark.parametrize("imaginary", [0.0, 0.5, np.nan, np.inf]) +def test_complex_uniforms_are_refused_without_lossy_conversion(model, field, imaginary): + draws = uniforms(model, 2) + draws[field][model.targets[-1]] = np.array( + [complex(0.25, imaginary), complex(0.75, imaginary)] + ) + with pytest.raises(ValueError, match="real numeric"): + model.predict_from_uniforms(pd.DataFrame({"x": [1.0, 2.0]}), **draws) + + +def test_empty_recipient_batch(model): + actual = model.predict_from_uniforms( + pd.DataFrame({"x": pd.Series(dtype=float)}), **uniforms(model, 0) + ) + assert list(actual.columns) == model.targets + assert actual.empty + assert all(dtype == np.dtype("float64") for dtype in actual.dtypes) + + +def test_zero_uniform_skips_a_zero_probability_sign(model, monkeypatch): + # Exercise the inverse-CDF boundary that ordinary RNG draws almost never hit. + gate = model._target_models["mixed"].gate + monkeypatch.setattr( + gate, "predict_proba", lambda x: np.tile([0.0, 0.0, 1.0], (len(x), 1)) + ) + draws = uniforms(model, 2) + draws["sign_uniforms"]["mixed"] = np.zeros(2) + actual = model.predict_from_uniforms(pd.DataFrame({"x": [1.0, 2.0]}), **draws) + assert (actual.mixed == 3.0).all() + + +def test_a_cdf_short_of_one_cannot_fall_back_to_the_first_sign(model, monkeypatch): + """The other half of the deliberate inverse-CDF change: the final bin closes. + + ``predict_proba`` rows are floating-point and need not sum to exactly 1.0. + Without ``cumulative[:, -1] = 1.0`` a uniform above that sum makes every + comparison false, and ``argmax`` on an all-false row returns 0 — silently + selecting the *first* class, here the negative one, for a draw that should + land in the last bin. Closing the bin is what the charter's amendment 20 + claims, so it is pinned rather than left to the strict-comparison test. + """ + gate = model._target_models["mixed"].gate + assert list(gate.classes_) == [-1, 0, 1] + short = np.array([0.5, 0.5 - 2e-16, 0.0]) + assert short.cumsum()[-1] < 1.0, "the row must fall short for this to bite" + monkeypatch.setattr(gate, "predict_proba", lambda x: np.tile(short, (len(x), 1))) + draws = uniforms(model, 2) + draws["sign_uniforms"]["mixed"] = np.full(2, 1.0 - 1e-16) + actual = model.predict_from_uniforms(pd.DataFrame({"x": [1.0, 2.0]}), **draws) + assert (actual.mixed == 3.0).all(), ( + "a uniform past a short CDF selected the first class, not the last" + ) + + +def test_stateless_replays_legacy_uniforms_across_all_regimes(model): + """Ordinary uniforms preserve legacy draws; exact CDF ties differ deliberately. + + The zero-uniform test above pins the intentional strict-CDF boundary. + This independent RNG replay detects swapped or inverted quantile streams. + """ + legacy = copy.deepcopy(model) + recipient = pd.DataFrame({"x": np.arange(20, dtype=float)}) + assert set(legacy.regimes().values()) == { + Regime.POSITIVE_ONLY, + Regime.NEGATIVE_ONLY, + Regime.DEGENERATE_ZERO, + Regime.THREE_SIGN, + Regime.ZERO_INFLATED_POSITIVE, + Regime.ZERO_INFLATED_NEGATIVE, + Regime.SIGN_ONLY, + } + for _ in range(4): + replay = np.random.default_rng() + replay.bit_generator.state = copy.deepcopy(legacy._rng.bit_generator.state) + quantiles, signs = {}, {} + for target in legacy.targets: + target_model = legacy._target_models[target] + quantiles[target] = ( + replay.random(len(recipient)) + if target_model.regime != Regime.DEGENERATE_ZERO + else np.zeros(len(recipient)) + ) + signs[target] = ( + replay.random(len(recipient)) + if target_model.gate is not None + else np.zeros(len(recipient)) + ) + expected = legacy.predict(recipient) + actual = legacy.predict_from_uniforms( + recipient, quantiles=quantiles, sign_uniforms=signs + ) + pd.testing.assert_frame_equal(expected, actual, check_exact=True) diff --git a/packages/microcosm-graph/src/microcosm/graph/__init__.py b/packages/microcosm-graph/src/microcosm/graph/__init__.py index d755feaa3..ef69b3fcc 100644 --- a/packages/microcosm-graph/src/microcosm/graph/__init__.py +++ b/packages/microcosm-graph/src/microcosm/graph/__init__.py @@ -56,6 +56,7 @@ source_hash, ) from .keys import platform_fingerprint +from .randomness import keyed_uniform __all__ = [ "platform_fingerprint", @@ -120,6 +121,7 @@ "explain_html", "graph_from_json", "graph_to_json", + "keyed_uniform", "load_source", "run_graph", "source_hash", diff --git a/packages/microcosm-graph/src/microcosm/graph/kernel.py b/packages/microcosm-graph/src/microcosm/graph/kernel.py index 0a7c95d01..d6b16909d 100644 --- a/packages/microcosm-graph/src/microcosm/graph/kernel.py +++ b/packages/microcosm-graph/src/microcosm/graph/kernel.py @@ -152,6 +152,7 @@ class SeedSource(StrEnum): EXECUTOR = "executor" # ``KernelContext.rng``, derived from the node key PARAM = "param" # a literal ``seed`` parameter (legacy parity kernels) + KEYED = "keyed" # normative stream params and stable draw coordinates NONE = "none" @@ -336,8 +337,9 @@ class KernelContext: in the node's inputs or outputs. strata: Read-only per-person strata of the population version. params: The node's parameters. - rng: A generator seeded from the node key. The only randomness a - kernel may use. + rng: The default generator seeded from the node key. KEYED kernels + instead use normative stream params and stable coordinates through + keyed_uniform; PARAM kernels use their declared literal seed. sources: Source name to a content-verified path, for declared sources only. artifacts: Declared artifact alias to its :class:`ArtifactValue`, diff --git a/packages/microcosm-graph/src/microcosm/graph/randomness.py b/packages/microcosm-graph/src/microcosm/graph/randomness.py new file mode 100644 index 000000000..453823c6b --- /dev/null +++ b/packages/microcosm-graph/src/microcosm/graph/randomness.py @@ -0,0 +1,78 @@ +"""Stable random coordinates, independent of graph packing and cache identity. + +A stream is ("sha256-u53-v1", experiment_id, replicate, base_seed). Each +nonempty coordinate tuple identifies a draw, conventionally (person_id, +process, period, draw_index). The top 53 bits of the SHA-256 digest, interpreted +big-endian, divided by 2**53 define a uniform in [0, 1). Stream parameters must +be normative node params; kernels must hash this module as implementing source +and declare SeedSource.KEYED. Repeated coordinates intentionally repeat draws. +""" + +from __future__ import annotations + +import hashlib +import math +from collections.abc import Sequence + +import numpy as np + +from .canonical import canonical_json + +__all__ = ["keyed_uniform"] + + +def _coordinate(value: object) -> list[object]: + if isinstance(value, np.generic): + # Temporal .item() can return a bare integer for fine-grained units, + # erasing both the coordinate type and its unit before tagging. + if value.dtype.kind not in "biufU": + raise TypeError( + "Random coordinates must be non-null finite scalar identities." + ) + value = value.item() + if isinstance(value, bool): + return ["bool", value] + if isinstance(value, int): + return ["int", value] + if isinstance(value, str): + return ["str", value] + if isinstance(value, float) and math.isfinite(value): + # Signed zero is one identity, as keys._canonical_tolerance_float treats + # it: -0.0 and 0.0 are equal and hash-equal, so they must draw alike. + return ["float", 0.0 if value == 0.0 else value] + raise TypeError("Random coordinates must be non-null finite scalar identities.") + + +def keyed_uniform(*, stream: tuple, keys: Sequence[tuple]) -> np.ndarray: + """Return bytes-backed, read-only float64 draws keyed by stable coordinates. + + Row order, chunk boundaries, and unrelated inserted identities cannot affect + a draw. Integers, strings, booleans and floats have distinct canonical tags; + a float coordinate of negative zero is the same identity as positive zero. + The experiment name is nonempty; replicate and base seed are non-negative + Python integers (booleans refused). No numpy RNG state is read or mutated. + """ + if not isinstance(stream, tuple) or len(stream) != 4: + raise TypeError( + "stream must be (algorithm, experiment_id, replicate, base_seed)." + ) + algorithm, experiment, replicate, base_seed = stream + if algorithm != "sha256-u53-v1": + raise ValueError(f"Unsupported random stream algorithm {algorithm!r}.") + if not isinstance(experiment, str) or not experiment: + raise ValueError("Random stream experiment_id must be non-empty.") + if any(type(value) is not int or value < 0 for value in (replicate, base_seed)): + raise ValueError( + "Random stream replicate/base_seed must be non-negative integers." + ) + prefix = b"microcosm-graph/keyed-uniform/1\0" + canonical_json(stream) + b"\0" + values = [] + for key in keys: + if not isinstance(key, tuple) or not key: + raise TypeError("Each random key must be a nonempty coordinate tuple.") + encoded = canonical_json([_coordinate(value) for value in key]) + digest = hashlib.sha256(prefix + encoded).digest() + values.append((int.from_bytes(digest[:8], "big") >> 11) / 2**53) + return np.frombuffer( + np.asarray(values, dtype=np.float64).tobytes(), dtype=np.float64 + ) diff --git a/packages/microcosm-graph/tests/fixtures/parity/kernels/fit.qrf/pins.json b/packages/microcosm-graph/tests/fixtures/parity/kernels/fit.qrf/pins.json index 1bfbebe22..fcb25cf92 100644 --- a/packages/microcosm-graph/tests/fixtures/parity/kernels/fit.qrf/pins.json +++ b/packages/microcosm-graph/tests/fixtures/parity/kernels/fit.qrf/pins.json @@ -1 +1 @@ -{"dependencies":{"numpy":"2.4.6","pandas":"3.0.3","quantile-forest":"1.4.2","scikit-learn":"1.8.0"},"implementation_hash":"02db8f5c849d876be20a95152b5302a5cacc0a7c77c58d8b436a3a00f57b4c92","kernel":"fit.qrf@1","node":"fit_qrf","node_key":"8878352db3439439f412f26c8762ff5871b94fe8e5fc8d3469dd1c45d7ef7da4","numeric":"platform_bitwise","platform":"arm64/darwin/py3.14","platforms":{"arm64/darwin/py3.14":{"direct":"direct.csv","node_key":"8878352db3439439f412f26c8762ff5871b94fe8e5fc8d3469dd1c45d7ef7da4"},"x86_64/linux/py3.13":{"direct":"platforms/x86_64-linux-py3_13/direct.csv","node_key":"f6984280e1ef0f156bd24345f7d3677d573c650ac706f9a3f949627c0d76d2d4"},"x86_64/linux/py3.14":{"direct":"platforms/x86_64-linux-py3_14/direct.csv","node_key":"9e80ee3ac5c30f99725c4dd932535983dabde7913c653e79819b1df3a289720c"}},"seed":947} +{"dependencies":{"numpy":"2.4.6","pandas":"3.0.3","quantile-forest":"1.4.2","scikit-learn":"1.8.0"},"implementation_hash":"57865faef2ded5dcf0bd97c3967b13de6a17e700e10e164c730b70ad2d8f2734","kernel":"fit.qrf@1","node":"fit_qrf","node_key":"97b471011e71a95d2f532376a27ac01f2cf433e62533ad41667b6173e91437be","numeric":"platform_bitwise","platform":"arm64/darwin/py3.14","platforms":{"arm64/darwin/py3.14":{"direct":"direct.csv","node_key":"97b471011e71a95d2f532376a27ac01f2cf433e62533ad41667b6173e91437be"},"x86_64/linux/py3.13":{"direct":"platforms/x86_64-linux-py3_13/direct.csv","node_key":"994be491cc9807d7c73ad4486e6d23a44860a474c0b838d49bffa962c576e27a"},"x86_64/linux/py3.14":{"direct":"platforms/x86_64-linux-py3_14/direct.csv","node_key":"b39682201a0ceabfcf2c306e8cc1b463cfc816241b87942e4ee4580e801aa80e"}},"seed":947} diff --git a/packages/microcosm-graph/tests/test_graph_kernel_contract.py b/packages/microcosm-graph/tests/test_graph_kernel_contract.py index 61a8fd2e2..885116345 100644 --- a/packages/microcosm-graph/tests/test_graph_kernel_contract.py +++ b/packages/microcosm-graph/tests/test_graph_kernel_contract.py @@ -1,14 +1,16 @@ -"""Kernel-protocol contracts of the frozen interface (amendments 13, 17, 19). +"""Kernel-protocol contracts of the frozen interface (amendments 13, 17, 19, 20). A kernel that claims bounded numeric movement declares the bound; a bitwise kernel declares none; the context hands readers their inputs' declared tolerances and numeric scopes; declared typed artifacts reach a consumer as -verified immutable bytes; and the new declaration fields round-trip through -JSON. +verified immutable bytes; a ``KEYED`` kernel declares that its draws come from +stable coordinates rather than from a position in the executor's generator; +and the new declaration fields round-trip through JSON. """ from __future__ import annotations +import copy import dataclasses import numpy as np @@ -31,12 +33,14 @@ Numeric, NumericScope, Owned, + SeedSource, Slice, SourceRef, StructuralDelta, Tolerance, graph_from_json, graph_to_json, + keyed_uniform, ) @@ -413,3 +417,92 @@ def test_artifact_declarations_round_trip_through_canonical_json() -> None: assert "artifact_inputs" not in plain_text assert "artifact_outputs" not in plain_text assert graph_from_json(plain_text) == plain + + +def test_seed_source_keyed_is_an_additive_declaration() -> None: + """Amendment 20: a third seed source, and the two others are untouched. + + ``KEYED`` says the kernel's draws come from normative stream parameters and + stable coordinates rather than from ``KernelContext.rng`` or a literal + ``seed`` param. The member is additive: every existing kernel keeps the + value it declares, so no existing node key moves (A5/A7). + """ + assert SeedSource.KEYED.value == "keyed" + assert SeedSource.EXECUTOR.value == "executor" + assert SeedSource.PARAM.value == "param" + assert SeedSource.NONE.value == "none" + assert set(SeedSource) == { + SeedSource.EXECUTOR, + SeedSource.PARAM, + SeedSource.KEYED, + SeedSource.NONE, + } + + keyed = Capabilities(determinism=Determinism.SEEDED, seed_source=SeedSource.KEYED) + assert keyed.seed_source is SeedSource.KEYED + with pytest.raises( + TypeError, match="Capabilities.seed_source must be a SeedSource" + ): + Capabilities(determinism=Determinism.SEEDED, seed_source="keyed") # type: ignore[arg-type] + + +def test_keyed_is_part_of_a_node_identity_and_survives_the_manifest() -> None: + """The declaration is contract, so it keys the node and round-trips. + + A kernel that reads the same inputs through a keyed stream is not the + kernel that reads them through the executor's generator; the capability + projection separates them, and a receipt spells the member back. + """ + from microcosm.graph.keys import _capabilities_projection + from microcosm.graph.manifest import _capability_contract_fields + + base = Capabilities(determinism=Determinism.SEEDED, seed_source=SeedSource.EXECUTOR) + keyed = dataclasses.replace(base, seed_source=SeedSource.KEYED) + projection = _capabilities_projection(keyed) + assert projection["seed_source"] == "keyed" + assert projection != _capabilities_projection(base) + assert _capability_contract_fields(projection)[2] is SeedSource.KEYED + + +def test_a_keyed_kernel_draws_from_coordinates_not_from_the_context_rng() -> None: + """The context still offers ``rng``; a keyed kernel simply does not spend it. + + Both halves of that sentence are asserted against a toy kernel body, which + is as far as this can go: the executor has no ``seed_source`` branch, so + there is no production path that treats a KEYED node differently and none + is claimed here. What the toy body pins is the shape a keyed kernel has — + it leaves the generator where it found it, and two contexts whose + generators sit 512 variates apart hand the same coordinates the same draws. + """ + node = Node("impute", "toy.keyed@1", params={"experiment": "amendment-20"}) + + def draw(context: KernelContext) -> np.ndarray: + return keyed_uniform( + stream=( + "sha256-u53-v1", + str(context.params["experiment"]), + 0, + 0, + ), + keys=[(person, "wages") for person in (11, 12, 13)], + ) + + def context_at(position: int) -> KernelContext: + rng = np.random.default_rng(7) + rng.random(position) + return KernelContext( + node=node, + tables={}, + weights={}, + strata=pd.Series(dtype="int64"), + params=node.params, + rng=rng, + ) + + early, late = context_at(0), context_at(512) + assert early.rng.bit_generator.state != late.rng.bit_generator.state + # "Does not spend it" is the half a drawing body could violate silently, so + # assert it rather than leave it to the closure's own restraint. + untouched = copy.deepcopy(early.rng.bit_generator.state) + assert draw(early).tobytes() == draw(late).tobytes() + assert early.rng.bit_generator.state == untouched diff --git a/packages/microcosm-graph/tests/test_graph_parity_pins.py b/packages/microcosm-graph/tests/test_graph_parity_pins.py new file mode 100644 index 000000000..57cbd3c03 --- /dev/null +++ b/packages/microcosm-graph/tests/test_graph_parity_pins.py @@ -0,0 +1,116 @@ +"""Every pinned H1 platform key is checkable from every platform. + +Charter H1 asserts a platform-bitwise kernel's *bytes* only on a platform that +carries a pin, and falls back to identity partitioning elsewhere. That is the +right rule for bytes — this machine cannot produce another architecture's +floats — but it leaves the foreign pins' *keys* unasserted everywhere except on +those machines, so a stale one survives until that CI lane happens to run. + +A node key is not a measurement. It is a pure function of the declaration, the +resolved input identities, the implementation hash, the capability projection, +and, for a platform-bitwise kernel (amendment 16), the platform fingerprint +string. So every pinned key is derivable here, and these tests derive all of +them. A kernel edit that re-pins only the local platform fails immediately +rather than on the next Linux run. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from microcosm.graph import graph_from_json, platform_fingerprint +from tools.graph_parity_fixtures import FIXTURES, parity_registry +from tools.graph_parity_repin import derived_node_key + +CASES = ("fit.qrf", "calibrate", "simulate") + + +def _case(name: str) -> tuple[Path, dict]: + case = FIXTURES / name + return case, json.loads((case / "pins.json").read_text()) + + +def _platforms(pins: dict) -> dict[str, dict]: + platforms = dict(pins.get("platforms", {})) + platforms.setdefault( + pins["platform"], {"node_key": pins["node_key"], "direct": "direct.csv"} + ) + return platforms + + +@pytest.mark.parametrize("name", CASES) +def test_every_pinned_platform_key_is_the_one_that_platform_would_compute( + name: str, +) -> None: + case, pins = _case(name) + graph = graph_from_json((case / "graph.json").read_text()) + for fingerprint, entry in sorted(_platforms(pins).items()): + derived = derived_node_key( + graph, pins["node"], case / "inputs.csv", fingerprint + ) + assert derived == entry["node_key"], ( + f"{name}: the pin for {fingerprint} is stale. Re-pin every platform " + f"with `uv run python tools/graph_parity_repin.py {name}` — a " + "kernel edit moves every platform's key, not only this machine's." + ) + + +@pytest.mark.parametrize("name", CASES) +def test_the_top_level_pin_is_the_authoring_platform_and_its_bytes_exist( + name: str, +) -> None: + case, pins = _case(name) + platforms = _platforms(pins) + # Read the raw mapping, not the one ``_platforms`` back-fills, or the + # assertion would be about this helper rather than about the fixture. + assert pins["platform"] in pins["platforms"] + assert pins["platforms"][pins["platform"]]["node_key"] == pins["node_key"] + for entry in platforms.values(): + direct = case / entry["direct"] + assert direct.is_file(), f"{name}: {entry['direct']} is pinned but absent" + assert direct.read_bytes(), f"{name}: {entry['direct']} is empty" + + +@pytest.mark.parametrize("name", CASES) +def test_the_pinned_implementation_hash_is_the_registered_kernel_s(name: str) -> None: + """A moved kernel must re-pin; H1 checks this too, without the platforms.""" + _, pins = _case(name) + kernel = parity_registry().get(pins["kernel"]) + assert kernel.implementation_hash() == pins["implementation_hash"] + assert set(pins["dependencies"]) == set(kernel.capabilities.dependencies) + + +def test_platform_bitwise_pins_partition_identity_across_platforms() -> None: + """Amendment 16: a shared store never serves another platform's output.""" + _, pins = _case("fit.qrf") + assert pins["numeric"] == "platform_bitwise" + platforms = _platforms(pins) + assert len(platforms) >= 2, "the whole point is more than one platform" + keys = [entry["node_key"] for entry in platforms.values()] + assert len(set(keys)) == len(keys) + + +def test_a_derived_key_is_the_local_platform_s_own_key() -> None: + """The derivation is not a second implementation of ``node_key``. + + Deriving every platform's key would be circular if the derivation were only + ever checked against itself. It is not: on whatever platform this runs, + ``test_h1_kernel_parity`` executes the graph and asserts that the executor's + key equals this same local pin, so agreeing with the local pin here anchors + the derivation to the executor. (The local pin is produced by a run on the + authoring platform and derived elsewhere, which is exactly why the anchor + has to come from an executed key rather than from the pin's provenance.) + """ + case, pins = _case("fit.qrf") + local = platform_fingerprint() + platforms = _platforms(pins) + if local not in platforms: + pytest.skip(f"{local} carries no fit.qrf pin") + graph = graph_from_json((case / "graph.json").read_text()) + assert ( + derived_node_key(graph, pins["node"], case / "inputs.csv", local) + == platforms[local]["node_key"] + ) diff --git a/packages/microcosm-graph/tests/test_graph_parity_repin.py b/packages/microcosm-graph/tests/test_graph_parity_repin.py new file mode 100644 index 000000000..54484d2b5 --- /dev/null +++ b/packages/microcosm-graph/tests/test_graph_parity_repin.py @@ -0,0 +1,248 @@ +"""What licenses ``graph_parity_repin`` to derive a key for a foreign platform. + +The tool writes node keys for platforms this machine is not. A node key folds +in the kernel's implementation hash, and ``source_hash`` folds the installed +version of every declared dependency into that hash — so the fingerprint string +is not the only platform-dependent input, and deriving a foreign key is sound +only while every pinned platform shares this machine's locked environment. + +These tests pin the refusals that establish that condition rather than assume +it, and the one that keeps pinned *bytes* from ever being derived. +""" + +from __future__ import annotations + +import json +import shutil +from collections.abc import Callable +from pathlib import Path + +import pytest + +from microcosm.graph import graph_from_json +from microcosm.graph.keys import platform_fingerprint +from tools import graph_parity_repin as repin_module +from tools.graph_parity_fixtures import parity_registry +from tools.graph_parity_repin import derived_node_key, repin + + +@pytest.fixture +def case_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callable[[str], Path]: + """Re-point the tool at a throwaway copy so a test can mutate a fixture.""" + + def make(name: str) -> Path: + root = tmp_path / "parity" + shutil.copytree(repin_module.FIXTURES, root) + monkeypatch.setattr(repin_module, "FIXTURES", root) + return root / name + + return make + + +def _pins(case: Path) -> dict: + return json.loads((case / "pins.json").read_text(encoding="utf-8")) + + +def _skip_unless_this_platform_is_pinned(case: Path) -> None: + """The tool refuses a platform with no pin before the step under test. + + On such a platform ``repin`` exits with "carries no pin to re-record", + which is neither the refusal these tests assert nor the no-op they + expect; the test cannot observe its subject there, so it skips. + """ + pins = _pins(case) + pinned = {pins["platform"], *pins.get("platforms", {})} + if platform_fingerprint() not in pinned: + pytest.skip( + f"{platform_fingerprint()} carries no {case.name} pin; the tool " + "refuses before the behaviour under test" + ) + + +def _write(case: Path, pins: dict) -> None: + (case / "pins.json").write_text( + json.dumps(pins, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + "\n", + encoding="utf-8", + ) + + +def test_the_recorded_dependency_versions_are_this_environment_s() -> None: + """The standing fact that makes every derived pin in the tree legitimate. + + ``pins["dependencies"]`` is the environment the existing keys were taken + under. While it equals this machine's, this machine's implementation hash + for that same source would have been the recorded one, so the pinned + platforms and this one shared a locked environment. + """ + case = repin_module.FIXTURES / "fit.qrf" + pins = _pins(case) + kernel = parity_registry().get(pins["kernel"]) + installed = repin_module.installed_dependency_versions(kernel) + assert pins["dependencies"] == installed + + +def test_a_recorded_dependency_version_this_machine_lacks_refuses( + case_copy: Callable[[str], Path], +) -> None: + """The channel the fingerprint string cannot capture, closed. + + A different ``numpy`` moves ``implementation_hash`` for a reason no platform + fingerprint records. Deriving a foreign key from this machine's hash would + then write a key that platform never computes, so the tool must refuse. + """ + case = case_copy("fit.qrf") + _skip_unless_this_platform_is_pinned(case) + pins = _pins(case) + pins["dependencies"]["numpy"] = "0.0.0-not-installed-here" + _write(case, pins) + + with pytest.raises(SystemExit) as raised: + repin("fit.qrf") + message = str(raised.value) + assert "numpy" in message + assert "0.0.0-not-installed-here" in message + + +def test_the_dependency_check_runs_before_any_key_is_derived( + case_copy: Callable[[str], Path], monkeypatch: pytest.MonkeyPatch +) -> None: + """Refusing after deriving would already have trusted the bad environment.""" + case = case_copy("fit.qrf") + _skip_unless_this_platform_is_pinned(case) + pins = _pins(case) + pins["dependencies"]["pandas"] = "0.0.0-not-installed-here" + _write(case, pins) + + def forbidden(*args: object, **kwargs: object) -> str: + raise AssertionError("a key was derived before the environment was checked") + + monkeypatch.setattr(repin_module, "derived_node_key", forbidden) + with pytest.raises(SystemExit): + repin("fit.qrf") + + +def test_a_foreign_pin_the_recorded_hash_cannot_reproduce_refuses( + case_copy: Callable[[str], Path], +) -> None: + """Every pin must be internally consistent with the hash recorded beside it. + + An entry that is not was written by something other than this tool under + these pins — a hand edit, or a platform pinned from a moved generator — and + re-deriving it here would launder that into a fresh-looking key. + """ + case = case_copy("fit.qrf") + _skip_unless_this_platform_is_pinned(case) + pins = _pins(case) + # Keep the authoring entry consistent with the top-level key so this + # reaches the foreign-key reproduction check on every pinned platform. + foreign = next( + p + for p in pins["platforms"] + if p not in {platform_fingerprint(), pins["platform"]} + ) + pins["platforms"][foreign]["node_key"] = "0" * 64 + _write(case, pins) + + with pytest.raises(SystemExit) as raised: + repin("fit.qrf") + assert foreign in str(raised.value) + + +def test_inconsistent_authoring_key_is_refused_before_derivation( + case_copy: Callable[[str], Path], monkeypatch: pytest.MonkeyPatch +) -> None: + case = case_copy("fit.qrf") + pins = _pins(case) + pins["node_key"] = "0" * 64 + _write(case, pins) + before = (case / "pins.json").read_bytes() + + def forbidden(*args: object, **kwargs: object) -> str: + raise AssertionError("derived a key from inconsistent authoring pins") + + monkeypatch.setattr(repin_module, "derived_node_key", forbidden) + with pytest.raises(SystemExit, match="authoring.*inconsistent"): + repin("fit.qrf") + assert (case / "pins.json").read_bytes() == before + + +def test_the_direct_bytes_compared_are_this_platform_s_own_pin( + case_copy: Callable[[str], Path], +) -> None: + """A platform-bitwise kernel's local bytes are not the authoring platform's. + + The tool must compare the direct call against the file *this* platform's + entry points at. Pointing the local entry at a copy while corrupting the + top-level ``direct.csv`` separates the two: reading the wrong file would + refuse here, and the run must instead reach the later reproduction check. + """ + case = case_copy("fit.qrf") + pins = _pins(case) + local = platform_fingerprint() + if local not in pins["platforms"]: + pytest.skip(f"{local} carries no fit.qrf pin") + + moved = Path("platforms") / "local-copy" / "direct.csv" + (case / moved).parent.mkdir(parents=True, exist_ok=True) + (case / moved).write_bytes((case / pins["platforms"][local]["direct"]).read_bytes()) + pins["platforms"][local]["direct"] = str(moved) + # The file the *old* comparison read, now holding bytes no platform produced. + (case / "direct.csv").write_bytes(b"not,the,direct,call\n") + # Force a refusal strictly after the byte comparison, so the message says + # which check the run reached. + foreign = next(p for p in pins["platforms"] if p not in {local, pins["platform"]}) + pins["platforms"][foreign]["node_key"] = "0" * 64 + _write(case, pins) + + with pytest.raises(SystemExit) as raised: + repin("fit.qrf") + message = str(raised.value) + assert "cannot reproduce" in message, message + assert "the direct call's bytes moved" not in message, message + + +def test_a_moved_direct_call_refuses_rather_than_re_pinning( + case_copy: Callable[[str], Path], +) -> None: + """Bytes are never derived: a moved direct call is not a re-pin at all.""" + case = case_copy("fit.qrf") + pins = _pins(case) + local = platform_fingerprint() + if local not in pins["platforms"]: + pytest.skip(f"{local} carries no fit.qrf pin") + (case / pins["platforms"][local]["direct"]).write_bytes(b"moved,bytes\n") + + with pytest.raises(SystemExit) as raised: + repin("fit.qrf") + assert "the direct call's bytes moved" in str(raised.value) + + +def test_derived_node_key_substitutes_only_the_named_node_s_hash() -> None: + """The substitution is what lets a pin be checked against its own hash.""" + case = repin_module.FIXTURES / "fit.qrf" + pins = _pins(case) + graph = graph_from_json((case / "graph.json").read_text()) + inputs = case / "inputs.csv" + local = platform_fingerprint() + + registered = derived_node_key(graph, pins["node"], inputs, local) + same = derived_node_key( + graph, pins["node"], inputs, local, pins["implementation_hash"] + ) + other = derived_node_key(graph, pins["node"], inputs, local, "0" * 64) + # The pins record the registered hash, so substituting it changes nothing; + # substituting a different one must move the key, or the parameter is inert. + assert same == registered + assert other != registered + + +def test_a_repin_of_an_unchanged_kernel_rewrites_the_pins_byte_for_byte( + case_copy: Callable[[str], Path], +) -> None: + """Idempotence: the tool is a no-op when nothing about the kernel moved.""" + case = case_copy("fit.qrf") + _skip_unless_this_platform_is_pinned(case) + before = (case / "pins.json").read_bytes() + repin("fit.qrf") + assert (case / "pins.json").read_bytes() == before diff --git a/packages/microcosm-graph/tests/test_graph_randomness.py b/packages/microcosm-graph/tests/test_graph_randomness.py new file mode 100644 index 000000000..a108bfa05 --- /dev/null +++ b/packages/microcosm-graph/tests/test_graph_randomness.py @@ -0,0 +1,229 @@ +"""Keyed draw streams (amendment 20): ``microcosm.graph.randomness``. + +A ``SeedSource.KEYED`` kernel does not consume an RNG in row order; it asks for +the uniform belonging to a coordinate. These tests hold ``keyed_uniform`` to the +three properties that make it worth having — stable coordinates give the same +draw, different coordinates give a different one, and nothing about packing, +ordering, or unrelated identities can reach a draw — plus the documented +``sha256-u53-v1`` formula, recomputed here from the specification rather than +read back from the implementation. +""" + +from __future__ import annotations + +import hashlib + +import numpy as np +import pytest + +from microcosm.graph import keyed_uniform +from microcosm.graph.canonical import canonical_json + +STREAM = ("sha256-u53-v1", "amendment-20", 0, 0) + + +def _spec_uniform(stream: tuple, key: tuple) -> float: + """The documented draw, derived here from the specification's own words. + + ``keyed_uniform``'s docstring defines a draw as the top 53 bits of the + SHA-256 digest over a domain prefix, the canonical stream, and the canonical + tagged coordinates, divided by ``2**53``. Recomputing it from that sentence + is the difference between testing the algorithm and testing the code. + """ + tags = {bool: "bool", int: "int", str: "str", float: "float"} + encoded = canonical_json([[tags[type(value)], value] for value in key]) + digest = hashlib.sha256( + b"microcosm-graph/keyed-uniform/1\0" + canonical_json(stream) + b"\0" + encoded + ).digest() + return (int.from_bytes(digest[:8], "big") >> 11) / 2**53 + + +def test_stable_coordinates_give_one_draw_and_different_ones_give_another() -> None: + keys = [(1, "wages", 2026, 0), (2, "wages", 2026, 0)] + first = keyed_uniform(stream=STREAM, keys=keys) + again = keyed_uniform(stream=STREAM, keys=keys) + + assert first.dtype == np.dtype("float64") + assert first.shape == (2,) + assert first.tobytes() == again.tobytes() + assert first[0] != first[1] + assert ((first >= 0.0) & (first < 1.0)).all() + + # Every coordinate position is live: moving any one of the four moves the + # draw, so a process, a period, or a draw index cannot collide with another. + base = keyed_uniform(stream=STREAM, keys=[(1, "wages", 2026, 0)])[0] + for moved in ( + (2, "wages", 2026, 0), + (1, "dividends", 2026, 0), + (1, "wages", 2027, 0), + (1, "wages", 2026, 1), + ): + assert keyed_uniform(stream=STREAM, keys=[moved])[0] != base + + +def test_repeated_coordinates_intentionally_repeat_the_draw() -> None: + """The docstring says so out loud; a row asked for twice gets one answer.""" + values = keyed_uniform(stream=STREAM, keys=[(7, "wages"), (7, "wages")]) + assert values[0] == values[1] + + +def test_a_draw_is_blind_to_order_batching_and_unrelated_identities() -> None: + """Packing cannot reach a draw: this is C1 and C2 for randomness.""" + keys = [(1, "wages"), (2, "wages"), (3, "wages")] + straight = keyed_uniform(stream=STREAM, keys=keys) + reversed_ = keyed_uniform(stream=STREAM, keys=list(reversed(keys))) + assert straight.tobytes() == reversed_[::-1].copy().tobytes() + + chunked = np.concatenate( + [ + keyed_uniform(stream=STREAM, keys=keys[:1]), + keyed_uniform(stream=STREAM, keys=keys[1:]), + ] + ) + assert straight.tobytes() == chunked.tobytes() + + # Inserting an unrelated identity ahead of a row leaves that row's draw + # alone — the failure mode a positionally consumed RNG cannot avoid. + with_intruder = keyed_uniform(stream=STREAM, keys=[(99, "wages"), *keys]) + assert with_intruder[1:].tobytes() == straight.tobytes() + + +def test_the_draw_is_the_documented_sha256_u53_formula() -> None: + keys = [(1, "wages", 2026, 0), (2, "wages", 2026, 0), ("h-3", True, -1.5)] + expected = np.asarray( + [_spec_uniform(STREAM, key) for key in keys], dtype=np.float64 + ) + assert keyed_uniform(stream=STREAM, keys=keys).tobytes() == expected.tobytes() + + +def test_streams_are_separated_by_experiment_replicate_and_base_seed() -> None: + key = [(1, "wages")] + base = keyed_uniform(stream=STREAM, keys=key)[0] + for stream in ( + ("sha256-u53-v1", "amendment-20-b", 0, 0), + ("sha256-u53-v1", "amendment-20", 1, 0), + ("sha256-u53-v1", "amendment-20", 0, 1), + ): + assert keyed_uniform(stream=stream, keys=key)[0] != base + + +def test_coordinate_types_are_tagged_so_look_alikes_do_not_collide() -> None: + """``True`` is not ``1`` and ``1`` is not ``"1"``: each carries its tag.""" + drawn = { + label: keyed_uniform(stream=STREAM, keys=[(value,)])[0] + for label, value in ( + ("bool", True), + ("int", 1), + ("str", "1"), + ("float", 1.0), + ) + } + assert len(set(drawn.values())) == 4 + + +def test_numpy_scalars_are_the_python_scalars_they_hold() -> None: + """A coordinate read out of a column must not draw differently.""" + assert ( + keyed_uniform(stream=STREAM, keys=[(np.int64(5), "wages")])[0] + == keyed_uniform(stream=STREAM, keys=[(5, "wages")])[0] + ) + assert ( + keyed_uniform(stream=STREAM, keys=[(np.bool_(True),)])[0] + == keyed_uniform(stream=STREAM, keys=[(True,)])[0] + ) + + +def test_negative_zero_is_the_same_float_coordinate_as_positive_zero() -> None: + """``-0.0 == 0.0`` and they hash alike, so they must draw alike. + + A negation or a CSV literal can hand a column ``-0.0`` where another run + holds ``0.0``; without normalisation ``canonical_json`` would spell them + differently and silently re-randomise that row. + """ + positive = keyed_uniform(stream=STREAM, keys=[(7, 0.0)])[0] + assert keyed_uniform(stream=STREAM, keys=[(7, -0.0)])[0] == positive + assert keyed_uniform(stream=STREAM, keys=[(7, np.float64(-0.0))])[0] == positive + # The normalisation is to positive zero: the documented formula over + # ``0.0`` is the draw both spellings produce. + assert positive == _spec_uniform(STREAM, (7, 0.0)) + + +@pytest.mark.parametrize("scalar", [np.datetime64, np.timedelta64]) +@pytest.mark.parametrize("unit", ["D", "s", "ns", "ps"]) +def test_temporal_coordinates_refuse_before_losing_type_or_units(scalar, unit): + # Fine temporal units become bare integers under .item(); they cannot be + # admitted as the same random identity as an integer or another unit. + with pytest.raises(TypeError, match="scalar identities"): + keyed_uniform(stream=STREAM, keys=[(scalar(1, unit),)]) + + +def test_draws_are_bytes_backed_and_read_only() -> None: + values = keyed_uniform(stream=STREAM, keys=[(1,), (2,)]) + assert not values.flags.writeable + with pytest.raises(ValueError): + values[0] = 0.5 + + +def test_an_empty_key_list_draws_nothing() -> None: + values = keyed_uniform(stream=STREAM, keys=[]) + assert values.dtype == np.dtype("float64") + assert values.shape == (0,) + + +def test_a_malformed_stream_is_refused() -> None: + with pytest.raises(TypeError, match="algorithm, experiment_id"): + keyed_uniform(stream=("sha256-u53-v1", "e", 0), keys=[(1,)]) + with pytest.raises(TypeError, match="algorithm, experiment_id"): + keyed_uniform(stream=["sha256-u53-v1", "e", 0, 0], keys=[(1,)]) # type: ignore[arg-type] + with pytest.raises(ValueError, match="Unsupported random stream algorithm"): + keyed_uniform(stream=("sha256-u53-v2", "e", 0, 0), keys=[(1,)]) + with pytest.raises(ValueError, match="experiment_id must be non-empty"): + keyed_uniform(stream=("sha256-u53-v1", "", 0, 0), keys=[(1,)]) + with pytest.raises(ValueError, match="non-negative integers"): + keyed_uniform(stream=("sha256-u53-v1", "e", -1, 0), keys=[(1,)]) + with pytest.raises(ValueError, match="non-negative integers"): + keyed_uniform(stream=("sha256-u53-v1", "e", 0, -1), keys=[(1,)]) + # A bool is not a replicate index; it spells an integer without being one. + with pytest.raises(ValueError, match="non-negative integers"): + keyed_uniform(stream=("sha256-u53-v1", "e", True, 0), keys=[(1,)]) + + +def test_a_malformed_coordinate_is_refused() -> None: + with pytest.raises(TypeError, match="nonempty coordinate tuple"): + keyed_uniform(stream=STREAM, keys=[()]) + with pytest.raises(TypeError, match="nonempty coordinate tuple"): + keyed_uniform(stream=STREAM, keys=[[1, "wages"]]) # type: ignore[list-item] + with pytest.raises(TypeError, match="non-null finite scalar identities"): + keyed_uniform(stream=STREAM, keys=[(None,)]) + with pytest.raises(TypeError, match="non-null finite scalar identities"): + keyed_uniform(stream=STREAM, keys=[(float("nan"),)]) + with pytest.raises(TypeError, match="non-null finite scalar identities"): + keyed_uniform(stream=STREAM, keys=[(float("inf"),)]) + with pytest.raises(TypeError, match="non-null finite scalar identities"): + keyed_uniform(stream=STREAM, keys=[({"person": 1},)]) # type: ignore[dict-item] + + +def test_keyed_draws_read_and_mutate_no_numpy_rng_state() -> None: + """The point of the amendment: a draw is not a position in a stream. + + Neither a generator nor the process-global legacy state moves, so a keyed + kernel cannot perturb an unrelated stream and cannot be perturbed by one. + """ + rng = np.random.default_rng(20) + generator_before = rng.bit_generator.state + global_before = np.random.get_state() + + values = keyed_uniform(stream=STREAM, keys=[(i, "wages") for i in range(64)]) + + assert rng.bit_generator.state == generator_before + after = np.random.get_state() + assert after[0] == global_before[0] + assert np.array_equal(after[1], global_before[1]) + assert after[2:] == global_before[2:] + # The same draws again, with a generator drained in between: consumption + # order elsewhere is invisible to a keyed draw. + rng.random(1_000) + assert ( + keyed_uniform(stream=STREAM, keys=[(i, "wages") for i in range(64)]).tobytes() + == values.tobytes() + ) diff --git a/tools/graph_parity_repin.py b/tools/graph_parity_repin.py new file mode 100644 index 000000000..e5b9b9707 --- /dev/null +++ b/tools/graph_parity_repin.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Re-record one H1 parity case's pins after an additive change to its kernel. + +``tools/graph_parity_fixtures.py`` regenerates a fixture from scratch. That is +the wrong instrument for a change that leaves a kernel's outputs alone but moves +its implementation hash, for two reasons: + +1. ``generate()`` rewrites ``pins["platforms"]`` as the local platform alone, so + running it here would silently drop the two pinned ``x86_64/linux`` entries + and leave charter H1 asserting no bytes on those platforms — its off-platform + branch asserts only identity partitioning. +2. ``ParityCsvSource`` and ``ParityRulesEngine`` are *defined in* that module, so + its bytes are inside ``ParityCsvSource.implementation_hash()`` and + ``SimulateRulesKernel.implementation_hash()``. Editing it to add a re-pin + path would move every parity node key in all three cases — churn caused by + the tooling rather than by the kernel under amendment. This module therefore + sits beside it and imports, so those bytes never move. + +What a re-pin may and may not derive: the local platform's node key is +**produced**, by running the graph. Every other pinned platform's key is +**derived**, because a node key is a pure function of the declaration, the +resolved input identities, the implementation hash, the capability projection, +and — for a platform-bitwise kernel (amendment 16) — the platform fingerprint +*string* (``microcosm.graph.keys.node_key``). + +The fingerprint is not the *only* platform-dependent input, and this does not +pretend otherwise: ``source_hash`` folds ``f"{distribution}=={version}"`` for +each declared dependency into the implementation hash, so a platform on a +different locked environment keys differently for a reason no fingerprint +string records. A re-pin therefore establishes the condition it needs, in two +checks that close different holes: + +1. ``pins["dependencies"]`` — the versions the existing keys were taken under — + must equal this machine's installed versions. This is the check that sees + the dependency channel. The reproduction below cannot see it, because + substituting the recorded implementation hash removes the only input those + versions reach. +2. Every pinned platform's key must reproduce from + ``pins["implementation_hash"]``. This catches an entry inconsistent with the + hash recorded beside it — a hand edit, or a platform pinned while the + generator's own bytes had moved — which comparing versions would not. + +Together they say the pinned platforms and this one shared one locked +environment at pin time, and every pin is the key that environment computed. +That is what licenses deriving the new foreign keys here. The local key is +additionally derived alongside being executed, so the derivation cannot drift +from what the executor computes. + +Pinned **bytes** are never derived: each platform's ``direct.csv`` is left +exactly as that platform recorded it, and a re-pin refuses to write at all if +the local direct call's bytes moved from the file **this** platform's pin points +at, because that would mean the change was not additive and the fixture needs a +real regeneration instead. + +Usage:: + + uv run python tools/graph_parity_repin.py +""" + +from __future__ import annotations + +import contextlib +import json +import os +import sys +from collections.abc import Iterator +from importlib import metadata as importlib_metadata +from pathlib import Path +from tempfile import TemporaryDirectory + +import pandas as pd + +from microcosm.graph import ContentStore, Graph, compile_graph, graph_to_json, run_graph +from microcosm.graph import keys as graph_keys +from microcosm.graph.keys import platform_fingerprint + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + # Importable both as ``tools.graph_parity_repin`` and as a bare script path. + sys.path.insert(0, str(ROOT)) + +# The pin schema and the case declarations belong to the generator; importing +# them is what keeps a re-pinned fixture indistinguishable from a generated one. +from tools.graph_parity_fixtures import ( # noqa: E402 - after the path bootstrap + FIXTURES, + _calibrate_case, + _fit_case, + _pins, + _simulate_case, + _write_pins, + parity_registry, +) + +__all__ = [ + "BUILDERS", + "derived_node_key", + "installed_dependency_versions", + "repin", +] + +BUILDERS = { + "fit.qrf": _fit_case, + "calibrate": _calibrate_case, + "simulate": _simulate_case, +} + + +@contextlib.contextmanager +def _as_platform(fingerprint: str) -> Iterator[None]: + """Compute keys as ``fingerprint`` would, without being that platform. + + The fingerprint reaches a key only as the string ``node_key`` appends for a + platform-bitwise kernel, so the string is the only thing to vary. See the + module docstring for why deriving a key is sound and deriving bytes is not. + """ + real = graph_keys.platform_fingerprint + graph_keys.platform_fingerprint = lambda: fingerprint + try: + yield + finally: + graph_keys.platform_fingerprint = real + + +def derived_node_key( + graph: Graph, + node_id: str, + inputs_path: Path, + fingerprint: str, + implementation_hash: str | None = None, +) -> str: + """The key ``fingerprint`` computes for ``node_id`` over ``inputs_path``. + + ``implementation_hash`` substitutes that value for ``node_id``'s own kernel, + so a caller can ask what a platform keyed under a *previous* implementation + — the one its existing pin was taken under — and check the answer against + that pin. Every other node keeps its registered kernel's hash. + """ + compiled = compile_graph(graph) + source_keys = { + source.name: graph_keys.source_content_key(source.name, inputs_path) + for source in graph.sources + } + registry = parity_registry() + keys: dict[str, str] = {} + with _as_platform(fingerprint): + for current in compiled.order: + kernel = registry.get(compiled.graph.node(current).kernel) + keys[current] = graph_keys.node_key( + compiled, + current, + keys, + implementation_hash + if current == node_id and implementation_hash is not None + else kernel.implementation_hash(), + source_keys, + kernel_capabilities=kernel.capabilities, + ) + return keys[node_id] + + +def installed_dependency_versions(kernel: object) -> dict[str, str]: + """This machine's versions of ``kernel``'s declared dependencies. + + Built exactly as ``graph_parity_fixtures._pins`` builds the mapping it + records, so the two are comparable without normalising either. + """ + capabilities = kernel.capabilities # type: ignore[attr-defined] + return { + name: importlib_metadata.version(name) + for name in sorted(capabilities.dependencies) + } + + +def _csv_bytes(frame: pd.DataFrame) -> bytes: + """The exact bytes ``_write_case`` writes for a pinned table.""" + return frame.to_csv(index=False, lineterminator="\n").encode("utf-8") + + +def _executed_node_key(name: str, graph: Graph, inputs_path: Path) -> str: + with TemporaryDirectory(prefix="microcosm-parity-repin-") as store_path: + manifest = run_graph( + compile_graph(graph), + sources={"fixture": inputs_path}, + store=ContentStore(Path(store_path)), + kernels=parity_registry(), + resume="forbid", + decisions=(), + ) + return manifest.nodes[graph.nodes[-1].id].key + + +def repin(name: str) -> dict[str, str]: + """Re-record ``name``'s pins in place, keeping every platform it carries. + + Returns the recorded node key per platform fingerprint. + """ + graph, inputs, direct, kernel, seed = BUILDERS[name]() + destination = FIXTURES / name + pins = json.loads((destination / "pins.json").read_text(encoding="utf-8")) + if pins["kernel"] != kernel.ref: # type: ignore[attr-defined] + raise SystemExit( + f"{name}: pins are for {pins['kernel']}, builder gives {kernel.ref}" # type: ignore[attr-defined] + ) + if (destination / "graph.json").read_text(encoding="utf-8") != graph_to_json(graph): + raise SystemExit(f"{name}: the declaration moved; regenerate instead") + stored_inputs = pd.read_csv( + destination / "inputs.csv", float_precision="round_trip" + ) + if not stored_inputs.equals(inputs.reset_index(drop=True)): + raise SystemExit( + f"{name}: inputs.csv no longer matches the builder; regenerate instead" + ) + fingerprint = platform_fingerprint() + platforms = dict(pins.get("platforms", {})) + if ( + pins["platform"] in platforms + and platforms[pins["platform"]]["node_key"] != pins["node_key"] + ): + raise SystemExit( + f"{name}: authoring platform and top-level key are inconsistent" + ) + platforms.setdefault( + pins["platform"], {"node_key": pins["node_key"], "direct": "direct.csv"} + ) + if fingerprint not in platforms: + raise SystemExit( + f"{name}: {fingerprint} carries no pin to re-record; " + "use graph_parity_fixtures.py platform-pin to add one" + ) + + # This platform's own pinned bytes, which for a platform-bitwise kernel are + # not the authoring platform's; comparing against the wrong file would refuse + # a legitimate re-pin off the authoring platform, or accept a drifted one. + local_direct = destination / platforms[fingerprint]["direct"] + if local_direct.read_bytes() != _csv_bytes(direct): + raise SystemExit( + f"{name}: the direct call's bytes moved from " + f"{platforms[fingerprint]['direct']}, so this is not a re-pin; " + "regenerate the fixture and say what changed" + ) + + node_id = pins["node"] + inputs_path = destination / "inputs.csv" + # Step 1 of the module docstring's argument, and the only step that sees the + # dependency channel. It has to come before any derivation: deriving first + # would already have trusted the environment under test. + installed = installed_dependency_versions(kernel) + if pins.get("dependencies") != installed: + drifted = sorted( + set(pins.get("dependencies", {})) | set(installed), + key=lambda dependency: dependency, + ) + detail = ", ".join( + f"{dependency}: pinned " + f"{pins.get('dependencies', {}).get(dependency, '(absent)')} vs " + f"installed {installed.get(dependency, '(absent)')}" + for dependency in drifted + if pins.get("dependencies", {}).get(dependency) != installed.get(dependency) + ) + raise SystemExit( + f"{name}: this environment is not the one the pins were taken " + f"under ({detail}). The implementation hash folds those versions " + "in, so a foreign platform's new key cannot be derived here; " + "re-pin from an environment matching uv.lock." + ) + + # Step 2: every pin must be internally consistent with the hash beside it. + for platform, entry in sorted(platforms.items()): + reproduced = derived_node_key( + graph, node_id, inputs_path, platform, pins["implementation_hash"] + ) + if reproduced != entry["node_key"]: + local = " (this machine)" if platform == fingerprint else "" + raise SystemExit( + f"{name}: cannot reproduce {platform}'s existing pin " + f"{entry['node_key']} from the recorded implementation hash " + f"(got {reproduced}){local}. The pin does not belong to the " + "hash recorded beside it, so re-deriving it here would launder " + "a stale or hand-edited key. Causes, in the order worth " + "checking: tools/graph_parity_fixtures.py's own bytes moved " + "(that re-keys every parity node, and this machine fails " + "first); the entry was edited by hand; or it was pinned on " + "that platform under a different implementation. Regenerate " + "the fixture, or re-pin that platform there." + ) + + produced = _executed_node_key(name, graph, inputs_path) + if derived_node_key(graph, node_id, inputs_path, fingerprint) != produced: + raise SystemExit( + f"{name}: the derived key disagrees with the executed one on " + f"{fingerprint}; a node key now depends on more than the platform " + "fingerprint string, so foreign platforms must re-pin themselves" + ) + + recorded = { + platform: { + "node_key": ( + produced + if platform == fingerprint + else derived_node_key(graph, node_id, inputs_path, platform) + ), + "direct": entry["direct"], + } + for platform, entry in platforms.items() + } + # The authoring platform is a fact about the fixture, not about the machine + # re-pinning it, so it survives ``_pins``; only its node key is re-recorded. + authoring = pins["platform"] + pins.update(_pins(node_id, recorded[authoring]["node_key"], kernel, seed)) + pins["platform"] = authoring + pins["platforms"] = recorded + _write_pins(destination, pins) + return {platform: entry["node_key"] for platform, entry in recorded.items()} + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if len(args) != 1 or args[0] not in BUILDERS: + raise SystemExit( + f"usage: graph_parity_repin.py <{' | '.join(sorted(BUILDERS))}>" + ) + os.environ["POPULACE_FIT_N_JOBS"] = "1" + os.environ["POPULACE_FIT_PREDICT_WORKERS"] = "1" + for platform, key in sorted(repin(args[0]).items()): + print(f"{args[0]} {platform} {key}") + return 0 + + +if __name__ == "__main__": + # source_hash includes module names, and the generator this imports defines + # two parity kernels; delegate to the canonical import so a pin never + # depends on how this file was invoked. + from tools.graph_parity_repin import main as canonical_main + + raise SystemExit(canonical_main())