diff --git a/PROGRESS.md b/PROGRESS.md index 53a600861..ae184206e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,3 +1,172 @@ +# Amendment 19 — typed opaque artifacts on the graph interface + +Lane: `amend-typed-artifacts`, off `origin/main` at `3094bfe84`. Started +2026-09-11. Everything below the `---` rule at the end of this section is +prior-lane history; see "Root journals are history, not state" in +`CLAUDE.md`. + +## State + +Landed, reviewed, and re-verified from scratch on `amend-typed-artifacts`. The +whole-workspace run is the last command outstanding; every gate the brief names +has been re-run green in this session. Nothing pushed, no PR, no branches +created, `uv.lock` untouched. + +> Historicized 2026-09-12: the branch was pushed as PR #911 on 2026-09-11 and +> peer-gated; the whole-workspace run above was superseded by the PR's CI. The +> interface lock is now enforced — `graph-interface-lock-test` merged as #910 +> on 2026-09-11 and this branch passes it. The paragraphs below are the lane's +> record as written, not current state. + +## Scope (what is in, and what is deliberately out) + +In, from `git diff origin/main origin/microcosm-us-launch-integration-20260909 +-- packages/microcosm-graph/src`: + +- `decl.py`: `ArtifactType`, `ArtifactOutput`, `ArtifactInput`, + `Node.artifact_inputs` / `Node.artifact_outputs`, their validation, their + elision from the canonical projection when empty, and the artifact-edge + arm of `compile_graph`. +- `kernel.py`: `ArtifactValue` and `KernelContext.artifacts`. +- `artifact_edges.py` (new): numeric scope payloads, scope compatibility, + typed descriptors, `typed_contracts`, `value_from_descriptor`. +- `keys.py`: `opaque_artifact_key` and the `typed_artifacts` term in + `node_key`. +- `serialize.py`, `view.py`, `manifest.py`, `executor.py`: the minimal + support for the executor to honour declared artifact inputs/outputs. + +Out, because it is not needed for artifacts (each is its own lane): + +- `SeedSource.KEYED` and `randomness.py` (`keyed_uniform`). +- `availability.py` / execution state / `unreached` / `blocked_by` / + `gate_exception` propagation, and manifest schema 4. +- `attachments.py`, `_PopulationRetention`, lazy populations, + `_population_observer`. +- `store.py` Frame-metadata storage (`microcosm-graph-frame-v2`) and the + non-finite JSON decode hooks. +- `keys.py` `_stream_file` chunked source hashing. +- `codecs.py` `SourceBytesCodec` / `load_source_bytes`; `schema.py`. +- The `_write_node` per-coordinate memory refactor. + +## Done + +- Read `CLAUDE.md`, `docs/graph-acceptance.md`, `DESIGN.md`, and the + amendment-17 precedent (`cdbf71888`, `80b63ba14`, `ed36f6cb3`). +- Measured the branch diff per file and fixed the in/out boundary above. +- `uv sync --all-packages --locked --extra us --extra uk` → exit 0. +- Captured the baseline node keys of the three toy acceptance graphs before + touching any source, so the node-key answer is measured, not asserted. +- `68a6ecc4b` (red, exit 2, 4 collection errors) → `e591c52d7`: the frozen + declaration interface, `compile_graph`'s artifact edge, the elided + canonical projection, `opaque_artifact_key`, serialization, and the view. +- `a2b6dfb0b`: the acceptance suite's B2 `KernelContext` field set, as its + own commit, matching `80b63ba14`. +- `1cce8eceb` (red, **8 of 8 failing** — the commit message and an earlier + version of this line both say 7, which is wrong; see the correction under + "Re-verification") → `15f9d2c67`: `artifact_edges.py` and + the executor, cache record, and manifest support. +- `38b9a9e4d`: amendment 19 in the charter, the relock, the changelog + fragment. `8c2e7faab`: the graph explorer's receipt payload. +- Node keys re-measured after the change: byte-identical for all 20 nodes + of the three toy graphs. +- `packages/microcosm-graph/tests` 359 passed, exit 0. + `tools/ci_test_groups.py --verify` ok, `tools/spec_engine_coverage.py + --check` 42156/42156 + 41/41, `tools/graph_acceptance_burndown.py + --verify` ok, `ruff check` and `ruff format --check` clean. + +- Ran a five-dimension adversarial review of the extraction against the + integration branch (fidelity/minimality, executor paths, identity and + store, manifest provenance, charter/lock/changelog), each finding put to + two skeptics. Nine findings; seven real and fixed here: + `3a0726f93` (a corrupt typed manifest surfaced as `NodeRejectedError` + rather than `StoreCorruptError`), `9ebb60e4b` (`ArtifactValue.key` + described as a content identity it is not; relock), `402d9a631` (a + malformed `gate_ancestry` regressed to a bare `TypeError` on manifests + with no artifacts at all; charter graph list corrected; the F2 sentence + split into its two mechanisms), `5c4a8efde` (the artifact miss decision + moved back inside the recompute fallback), `9018c4420` (the + identity-preservation claim corrected, and payloads read only on the path + that runs a kernel). Two were the documented decisions and stand. +- Added coverage the review motivated: manifest ancestry authentication, + the F2-over-bytes path, cross-version edges under all three resume + policies, every artifact declaration field being normative, A3 through a + byte edge, and a cache hit that reads no payload. + +## Re-verification, 2026-09-11 (independent of the landing session) + +Everything below was re-run from a clean read of the tree, not carried over +from the landing session's notes. + +- The node-key answer re-measured with a script that varies only the + graph-kernel code: `microcosm.graph` resolved once from this branch and once + from `origin/main`'s sources (shadowed through `PYTHONPATH`, confirmed by the + loaded `decl.py` hash `635fef92...` on the main run), with `microcosm.build` + identical in both. Six graphs, 5+5+6+9+41+8 = 74 nodes: **all 74 node keys and + all 74 canonical projections byte-identical.** The amendment's per-graph counts + are each correct. +- `docs/graph-interface.lock` re-checked against `shasum -a 256` of the two + frozen files: both match. +- Re-run green: `packages/microcosm-graph/tests` 370 passed exit 0; the + acceptance subset 113 passed exit 0; `test_graph_kernel_contract.py` 15 passed + exit 0; the `KernelContext(` consumers (calibrate/fit/frame `test_kernels.py` + plus `test_us_graph.py`, `test_uk_graph.py`) 36 passed exit 0. + `tools/ci_test_groups.py --verify` ok, `tools/spec_engine_coverage.py --check` + 42156/42156 + 41/41, `tools/graph_acceptance_burndown.py --verify` ok, + `ruff check .` clean — all exit 0. +- No consumer constructs `KernelContext` positionally: all five non-test sites + use keyword arguments, so the new field's placement could not have broken one. +- `ruff format --check .` exits 1 on 81 pre-existing files, none of them touched + by this lane (all 17 changed Python files pass `ruff format --check` + individually). CI's lint lane runs only `ruff check .`, so this is repo drift, + not a gate this lane moved. +- The red commit `1cce8eceb` records "Red: 7 of 8 fail against the executor as + it stands", and this journal repeated it. **It was 8 of 8.** Measured by + extracting the whole tree at `1cce8eceb` (`git archive | tar -x`), pointing + `PYTHONPATH` at that tree's six shard `src` directories (confirmed: + `microcosm.graph.executor` resolves into the extract, and + `microcosm.graph.artifact_edges` has no spec there, so the executor support + genuinely had not landed), and running the commit's own + `test_graph_executor.py` against its own sources: **8 failed, 63 passed**, the + 8 being exactly the amendment-19 tests the commit added. Red-first discipline + holds — the commit was redder than claimed — but the count in its message is + wrong and stays wrong, because rewriting landed history to fix a tally would + be worse than recording the correction here. +- An adversarial audit line-traced the new module: eleven non-docstring + statements of `artifact_edges.py` never executed in the whole graph suite, all + on the foreign-provenance parsing surface. Closed in `e3f69a4c4` with three + tests through the public `NodeReceipt`/`RunManifest` surface; the trace now + reports zero. The same trace showed `run_graph`'s consumer-side receipt + comparison is unreachable as a refusal — both skeptics confirmed the charter's + wording claims only the check's ordering, which does execute — so the branch + is now commented the way this file already marks such guards, rather than + chased with a test that cannot be written honestly. +- `packages/microcosm-build/tests/test_release_target_parity.py` fails two + tests locally. **Not this lane, and not CI**: both are guarded by + `_feed_or_skip` on a 131 MB pinned feed that lives *outside the repository* + (`~/PolicyEngine/_buildh-runtime/inputs/consumer_facts_buildn_v9_4.jsonl`, + dated 2026-07-23), so CI skips them; the local artifact predates #855's + hierarchy-label requirement. The same two fail identically with `origin/main`'s + graph sources swapped in, and `ledger_targets.py` imports no + `microcosm.graph`. This is the US twin of the UK instance the #791 lane already + recorded in `experiments/791-household-composition-receipts.md:111`. + +## Next + +- Whole-workspace `uv run pytest` is the only command still running. Every gate + the brief names is green at `HEAD`, and the complete set of tests this change + can reach — the graph package (373) plus the 8 test files outside it that + import `microcosm.graph` directly or through the seven source modules that do + (81) — is green at exit 0. The lane report is in `out.md`. +- For Max, in `out.md` §8: the gate-artifact-output refusal is the one interim + ruling this lane made on his behalf; the interface lock had **no automated + enforcement** when this was written (the charter's freeze was a human gate, + which is how the integration branch changed both frozen files unnoticed) — + the sibling branch `graph-interface-lock-test` (`8bd6e05ec`) added the test + and merged as #910 on 2026-09-11; this branch passes it, exit 0; plus the pre-existing + `ruff format` drift and the stale local `_buildh-runtime` feed. + +--- + # Issue #907 — population `_storage_parts` hashes object dtype by pointer Lane: `fix-907-population-stamp-object-storage`, branched from diff --git a/changelog.d/amend-typed-artifacts.added.md b/changelog.d/amend-typed-artifacts.added.md new file mode 100644 index 000000000..e0ea11001 --- /dev/null +++ b/changelog.d/amend-typed-artifacts.added.md @@ -0,0 +1,3 @@ +Graph interface amendment 19, typed opaque artifacts: `ArtifactType`, `ArtifactOutput`, `ArtifactInput` and `Node.artifact_inputs`/`Node.artifact_outputs` in `decl.py`, `ArtifactValue` and `KernelContext.artifacts` in `kernel.py`, and the executor support that honours them — `compile_graph` resolves every declared byte edge and makes the producer a predecessor, the executor hands a consumer verified immutable bytes carrying their producer's numeric scope (which a typed edge may not launder), a kernel that omits a declared output is rejected, and the typed contract is pinned in the cache record and in the run manifest's authenticated provenance. The declarations are elided from the canonical projection when empty, so no node key moves for a graph that declares no artifacts. + +A gate kernel may not declare a typed artifact output: a gate exception is a verdict (amendment 7), so its synthesized result carries no artifacts and amendment 19 carries no regime for an unproduced output. diff --git a/docs/graph-acceptance.md b/docs/graph-acceptance.md index 4f602d4a7..71ef7fffb 100644 --- a/docs/graph-acceptance.md +++ b/docs/graph-acceptance.md @@ -292,9 +292,97 @@ Amendments so far (each re-locked): `hit` forced to false) and `load_certified` refuses it. Raised by the #847 gate review; adopted 2026-09-03. +19. **Typed opaque artifacts.** A build has byte dependencies that are not + cells — a fitted forest, a transfer matrix, a prepared table another + node reads whole. Until now the only channel was + `KernelResult.artifacts`: undeclared bytes, invisible to the compiler, + outside every key, and unreadable by any other node, so the real edge + was carried out of band. `decl.py` gains `ArtifactType` (a nominal + `name` and positive `schema_version`; the graph never parses the + payload), `ArtifactOutput` (a named, typed subset of the bytes a kernel + already returns) and `ArtifactInput` (a consumer-local alias naming a + producer, its output, and the exact type), plus `Node.artifact_inputs` + and `Node.artifact_outputs`. `kernel.py` gains `ArtifactValue` — + immutable bytes with the artifact's store identity (derived from the + producing node's key and the output name, not a hash of the payload; + the store validates the bytes against their own recorded SHA-256 on + load, as E1 requires), its producer's node key, and the producer's + `NumericScope` — and + `KernelContext.artifacts`, one value per declared alias. It rides + before `tolerances`, so amendment 17's statement that `numerics` rides + at the end of the context stays literally true; the acceptance suite's + B2 field set gains it in its own commit. Undeclared diagnostic bytes + remain legal and stay unaddressable. + + `compile_graph` resolves every edge and refuses an unknown producer, an + undeclared output, a type the producer does not declare, and + self-dependence; the producer becomes a predecessor, so an artifact + cycle is refused by the same depth computation as a cell cycle and C3's + "declared predecessors only" now covers bytes as well as columns. The + executor loads each declared input from the store after checking the + producer receipt's identity, hands over verified values, and folds the + payload and its provenance into the input context digest, so B4's + mutation check covers artifacts. A kernel that omits a declared output + is rejected; a cached record that lacks one is a miss. Bytes carry + their producer's numeric class across the edge and may not launder it: + a `platform_bitwise` or `tolerance_bound` payload requires a consumer + of the same class (amendments 16 and 17), because opaque bytes have no + per-cell coordinates to scope. The typed contract is pinned in the + cache record (its schema moves to 2 only for a node that declares + artifacts) and in `NodeReceipt.typed_artifacts`; a run manifest + carrying any typed edge serializes at schema 3 and authenticates every + edge on load. F2 is held by two separate mechanisms. In a run, a byte + edge joins the same predecessor set as a cell edge, so the executor's + tier derivation walks it and a gate reachable only through bytes is in + the release's `gate_ancestry`. On load, the manifest additionally + refuses a release whose `gate_ancestry` omits a gate in its typed + ancestry — which binds only on a manifest produced elsewhere, since a + gate in this codebase cannot be an artifact producer (see the refusal + below), and is there so a foreign manifest cannot claim otherwise. + `keys.py` exposes `opaque_artifact_key` under the domain and formula the + executor already used for undeclared opaque outputs, so typed and + undeclared bytes share one derivation and the amendment introduces no + second identity scheme. A given output does **not** keep its identity + when a type is declared for it: `artifact_outputs` is normative, so the + declaration moves the producing node's key and the output's identity + moves with it, like any other normative declaration. + + One shape is refused rather than modelled: a **gate kernel may not + declare a typed artifact output**. A gate whose kernel raises becomes a + `fail` verdict and the run continues (amendment 7), so its synthesized + result carries no artifacts, and a declared output would turn that + verdict into an aborted run. Amendment 19 carries no regime for an + output a node was unable to produce and for the consumers that are + therefore unreachable; until one is adopted the executor refuses the + declaration outright, so amendment 7 stays literally true for every + legal node shape. + + **Node keys do not move.** Unlike amendments 11's `entrants` and 12's + `mass_partition`, the two new fields are normative but elided from the + canonical projection when empty, and a consumer's `typed_artifacts` + term is added only when it declares an input — so a node that declares + no artifacts projects, keys, and serializes exactly as it did before. + Measured rather than asserted: the whole `microcosm-graph` acceptance + suite is green with no re-pin, and every node key of six graphs is + byte-identical when computed against `origin/main`'s sources and against + this amendment's — `_toy.small_graph()` (5 nodes), + `_toy.chained_graph()` (5), `_toy.chained_graph(leaves=("leaf_a",))` + (6), `_toy.full_graph()` (9), `uk_spine_graph(load_country_spec("uk"))` + (41) and `us_post_transfer_graph()` (8), 74 node keys in all. Keys move + for a node that declares an artifact edge and, by A3, for every + descendant of either end — the consumer's cell readers, and any + structural node whose base version contains the producer or consumer, + since its key binds every member's key. No graph on `main` declares an + edge, so no key there moves. + 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. + 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 13's sibling field `entrants`; no released artifact pins a graph key yet. +Amendment 19 elides its two fields when they are empty instead, so keys +move only for the nodes that use them. ## Ownership diff --git a/docs/graph-interface.lock b/docs/graph-interface.lock index cabec2d32..6d6d4eceb 100644 --- a/docs/graph-interface.lock +++ b/docs/graph-interface.lock @@ -1,2 +1,2 @@ -635fef92c599c298e7f19ca0badfa85aa040bf8e81eafed59f37c48db1fcff06 decl.py -eaf07da2eded1b1895aa0c59f603eb93744ed928df65aa9e65aa633762833949 kernel.py +ed0a859adcae12510d5ba74d51c694617201f7b448b108a3f602410f5da44876 decl.py +97c3ec9fc38aacb58f99e69c5a913ffca72c897cea4ca1002659ef06198b2b33 kernel.py diff --git a/out.md b/out.md index 9d88868ec..35bba7a05 100644 --- a/out.md +++ b/out.md @@ -1,117 +1,519 @@ -# F1 portable worker identity — Sol gate round 1 report +# Amendment 19 — typed opaque artifacts on the graph interface + +Lane report. Branch `amend-typed-artifacts`, off `origin/main` at `3094bfe84`, +in the worktree `~/PolicyEngine/_worktrees/microcosm-amend-artifacts`. +Date 2026-09-11. **Nothing pushed, no PR opened, no branch created, `uv.lock` +untouched, the stash never used.** + +> Historicized 2026-09-12: written before the push; the branch is PR #911 (pushed 2026-09-11, peer-gated). The lock-enforcement test named in §8 merged as #910 on 2026-09-11. + + +The interface change the integration branch +`origin/microcosm-us-launch-integration-20260909` had made to the two frozen +files without an amendment is now extracted and landed the charter's way: +declaration types and kernel context field, the executor support that makes +them mean something, amendment entry 19, a re-recorded lock, contract tests, +the frozen acceptance suite's B2 field set in its own commit, a changelog +fragment, and sorted exports. + +--- + +## 1. Commits + +Twenty-six commits, `3094bfe84..HEAD`, oldest first. The red commits are +marked; each is followed by the commit that makes it green. + +| SHA | Subject | +| --- | --- | +| `d88e86d5b` | Open the Amendment 19 lane journal (typed opaque artifacts) | +| `68a6ecc4b` | **(red)** Amendment 19 (red): declaration contracts for typed opaque artifacts | +| `e591c52d7` | Amendment 19: typed opaque artifacts on the frozen declaration interface | +| `a2b6dfb0b` | **(frozen acceptance suite, alone)** B2: the kernel context carries declared artifacts | +| `1cce8eceb` | **(red)** Amendment 19 (red): executor contracts for typed artifact edges | +| `15f9d2c67` | Amendment 19: the executor honours declared artifact edges | +| `38b9a9e4d` | Amendment 19 in the charter; relock decl.py and kernel.py | +| `8c2e7faab` | The graph explorer shows a node's typed artifact provenance | +| `41fb8bc94` | Record the Amendment 19 lane's landed commits and gate results | +| `51362a506` | A gate kernel may not declare a typed artifact output | +| `749640255` | A gate reached only through a byte edge still derives the release tier | +| `433996d6f` | Widen the amendment 19 node-key measurement to both country graphs | +| `b4ac66c1f` | The manifest authenticates typed artifact edges on load | +| `5a13c1abf` | Artifact edges cross population versions under every resume policy | +| `3a0726f93` | A corrupt typed manifest loads as StoreCorruptError, not NodeRejectedError | +| `9ebb60e4b` | Say what ArtifactValue.key actually is; relock kernel.py | +| `402d9a631` | Keep gate_ancestry validation where it was; name the graphs actually measured | +| `5c4a8efde` | Keep the artifact miss decision inside the recompute fallback | +| `64ae64867` | Assert every field of a declared artifact edge is normative | +| `6037ce458` | Descendant-exact invalidation reaches through a byte edge (A3) | +| `9018c4420` | Typed bytes keep the derivation, not the identity; read payloads only to run | +| `79ef58e9b` | Record the adversarial review and what it changed | +| `df04a9375` | Re-verify the Amendment 19 lane end to end, independently of the landing | +| `315119e65` | Write the Amendment 19 lane report | +| `e3f69a4c4` | Exercise every refusal a foreign typed descriptor can trip | +| `0c22b9d30` | Correct the red-commit count: 1cce8eceb was 8 of 8, not 7 | + +--- + +## 2. Diff summary, per file + +`git diff --stat origin/main...HEAD` — 22 files, +2784 / −121. (The 121 +deletions are almost entirely this file: `out.md` still held an unrelated +earlier lane's report and was replaced.) + +### The two frozen files + +**`packages/microcosm-graph/src/microcosm/graph/decl.py`** (+137 / −1) + +- `ArtifactType` — a nominal `name` and a positive `schema_version`. The graph + never parses a payload; it carries the pair and refuses a mismatch. +- `ArtifactOutput` — `name` (the key the kernel already returns bytes under) + and `type`. Declaring it is what makes the output addressable and *required*. +- `ArtifactInput` — `name` (consumer-local alias), `producer`, `artifact` + (the producer's output name), `type` (exact). +- `Node.artifact_inputs` / `Node.artifact_outputs`, both defaulting to `()`, + validated in `__post_init__` for tuple-ness, element type, and unique names. +- `Node.normative()` elides both fields when empty — the mechanism behind the + "no node key moves" answer in §4. +- `compile_graph` gains the artifact-edge arm: refuses self-dependence, an + unknown producer, an undeclared output, and a type the producer does not + declare; on success adds the producer to `predecessors[node.id]`, so an + artifact cycle is refused by the same depth computation as a cell cycle. + +**`packages/microcosm-graph/src/microcosm/graph/kernel.py`** (+72 / −1) + +- `ArtifactValue` — frozen `payload: bytes`, `type: ArtifactType`, `key: str`, + `producer_key: str`, `numerics: NumericScope`, with `__post_init__` + validating the payload is `bytes`, the type and scope are the right classes, + and both identities are 64-char lowercase hex. +- `KernelContext.artifacts: Mapping[str, ArtifactValue]`, defaulting to `{}`, + placed **before** `tolerances`, with a new `__post_init__` that rejects an + empty or non-string alias and a non-`ArtifactValue` value, then re-binds the + field to a `MappingProxyType` over a defensive copy. + +### Executor-side support (the minimal coherent subset) + +**`artifact_edges.py`** (new, 210 lines) — the glue between declaration and +kernel. `numeric_scope`, `scope_payload`, `scope_from_payload`, +`require_compatible_scope` (the anti-laundering rule), +`descriptor` (the portable per-edge provenance a receipt records), +`typed_contracts` (every typed edge of one node, resolved against the compiled +graph; empty for a node declaring none), and `value_from_descriptor` (rebuilds +a verified `ArtifactValue`, re-deriving and checking the identity). + +**`keys.py`** (+41) — `opaque_artifact_key(node_key, name)` promoted from the +executor's private `_opaque_artifact_key` into the public key module under the +identical domain and formula (`sha256_domain("node-artifact", …)`), so typed +and undeclared bytes share one derivation; and a `typed_artifacts` term added +to `node_key` **only** when the node declares an input. + +**`executor.py`** (+121 / −3) — `_opaque_artifact_key` now delegates to +`graph_keys.opaque_artifact_key`; `_context_digest` folds each artifact's +alias, identities, type, scope and payload in (so B4's mutation check covers +artifacts); `_project_context` takes and passes `artifacts`; `_validate_result` +rejects a kernel that omits a declared output (`StoreCorrupt` on a restored +result, `NodeRejected` on a fresh one); `_write_node` records +`typed_artifacts` and moves the cache record to schema 2 **only** when the node +declares artifacts; `_require_record_shape` requires the contract to match, and +treats a missing declared output as a *miss* and a wrong identity as +*corruption*; `run_graph` refuses a gate kernel that declares an artifact +output, authenticates each edge against the producer receipt before deciding +hit-or-miss, and loads payloads only on the recompute path. + +**`manifest.py`** (+140 / −2) — `NodeReceipt.typed_artifacts` (kw-only, +validated, frozen, elided from the payload when empty); +`_TYPED_SCHEMA_VERSION = 3`, emitted only when some receipt carries a typed +edge and refused on load if a v3 manifest carries none; `typed_artifacts` in a +receipt payload requires schema 3; and `_validate_typed_ancestry`, which on +every construction checks each output descriptor names its own node and key, +each input resolves to a producer whose output descriptor is byte-identical, +the scope matches the producer's declared capabilities and is one the consumer +may read, the edges form a DAG, and a release's `gate_ancestry` covers gates +reached through bytes. + +**`serialize.py`** (+74) — both declarations serialize and round-trip, elided +when empty so a pre-amendment graph's JSON is byte-identical. + +**`view.py`** (+17), **`explain.py`** (+5) — `describe` shows declared edges, +and the explorer's receipt payload carries `typed_artifacts` when present. + +**`__init__.py`** (+8) — `ArtifactInput`, `ArtifactOutput`, `ArtifactType` +(from `decl`) and `ArtifactValue` (from `kernel`) added to the imports and to +`__all__`, in sorted position, per `ed36f6cb3`. + +### Charter, lock, changelog, tests + +- `docs/graph-acceptance.md` (+85) — amendment entry 19. +- `docs/graph-interface.lock` (±2) — both hashes re-recorded. +- `changelog.d/amend-typed-artifacts.added.md` (new, 3 lines). +- `packages/microcosm-graph/tests/` (+1346 / −3) — 36 new tests across seven + files, of which exactly one line is in the frozen acceptance suite. + +### What was deliberately left on the integration branch + +The branch's `packages/microcosm-graph/src` diff against main is +2165 across +17 files; this extraction is +818 across 10. Excluded entirely, each its own +lane: `SeedSource.KEYED` and `randomness.py`; `availability.py` and the +execution-state / `unreached` / `blocked_by` / `gate_exception` machinery with +its manifest schema 4; `attachments.py`, `_PopulationRetention` and lazy +populations; `store.py`'s Frame-metadata `microcosm-graph-frame-v2` and +non-finite JSON decode hooks; `keys.py`'s `_stream_file` chunked source +hashing; `codecs.py`'s `SourceBytesCodec`; `schema.py`; and the `_write_node` +per-coordinate memory refactor. + +`store.py` needed no change at all: `load_bytes`, `load_json` and the +`put_bytes` write of opaque artifacts under `opaque_artifact_key` all already +exist on `origin/main` (`store.py:952`, `store.py:983`, +`executor.py:1456` on main). The typed layer rides on that machinery, which is +why the amendment introduces no second identity scheme. + +--- + +## 3. Field placement + +`artifacts` was placed **before** `tolerances`, as the brief directed, not last +as the integration branch had it. There was no documented reason on the branch +for the trailing position, and the leading position keeps amendment 17's +contract assertion literally true. Both assertions now hold and are enforced: + +- `test_graph_kernel_contract.py:204` (amendment 17, untouched): + `assert fields[-2:] == ["tolerances", "numerics"]` +- `test_graph_kernel_contract.py:338` (amendment 19, new): the same assertion + again, plus `assert fields[fields.index("artifacts") + 1] == "tolerances"`. + +The amendment entry states the choice: *"It rides before `tolerances`, so +amendment 17's statement that `numerics` rides at the end of the context stays +literally true."* + +--- + +## 4. The node-key answer, measured + +**No node key moves for any graph that declares no artifact edge — and there +are none on `main`. Where an edge is declared, keys move for its two ends and, +by A3, for every descendant of either end (the consumer's cell readers and any +structural node whose base version contains the producer or consumer).** + +`Node.artifact_inputs` and `Node.artifact_outputs` are normative fields with +defaults, which for amendments 11 and 13's sibling field `entrants` meant every +node's projection and key moved. This amendment avoids that by eliding both +fields from `Node.normative()` when empty, and by adding the `typed_artifacts` +term to `node_key` only when the node declares an input. + +### Evidence — measured twice, independently + +The landing session measured it; this session re-measured it from scratch with +a script that resolves `microcosm.graph` **twice from one process image** — +once from this branch's sources and once from `origin/main`'s, shadowed through +`PYTHONPATH` — while `microcosm.build` stays identical in both runs, so the +only variable is the graph-kernel code. -Date: 2026-09-04 - -Branch: `f1-portable-worker-identity` - -## State - -Implementation is in progress. The four findings have been reproduced with -new regression tests on the `b26708a1` implementation. The only preceding -branch change was the committed progress-journal initialization. - -## Environment preparation - -- `uv sync --all-packages --locked --extra us --extra uk` exited 2 because the - runner exports `UV_FROZEN`, which `uv` rejects together with `--locked`. -- `env -u UV_FROZEN uv sync --all-packages --locked --extra us --extra uk` - exited 2 because the sandbox does not permit uv to initialize its default - cache under `/Users/maxghenis/.cache/uv`. -- `env -u UV_FROZEN UV_CACHE_DIR=/private/tmp/microcosm-uv-cache uv sync - --all-packages --locked --extra us --extra uk` exited 0: 125 packages - resolved and 103 packages checked. -- All pytest commands below likewise set the writable `UV_CACHE_DIR` and use - the required `uv run --no-sync` mode. - -## Finding 1 — schema-9 stacked-envelope bypass - -Reproduction test: -`test_scoring_loader_requires_complete_schema_nine_stacked_envelope`. - -Command: - -```sh -UV_CACHE_DIR=/private/tmp/microcosm-uv-cache uv run --no-sync pytest -q packages/microcosm-build/tests/test_us_multispine_pool_h5_io.py::test_scoring_loader_accepts_legacy_worker_alias_relocation_only packages/microcosm-build/tests/test_us_multispine_pool_h5_io.py::test_scoring_loader_requires_complete_schema_nine_stacked_envelope ``` - -Result: exit 1; 1 passed and 1 failed. The new regression showed that missing -and wrong `pipeline` values were accepted. Omitting `operator_order`, -`sampling`, `stack_manifest`, `geography_assignment`, or `stage_receipts` -reached later validators instead of the common stacked-envelope refusal. The -existing valid schema-9 path passed and restored both stacked frame-metadata -anchors. - -Fix: pending. - -Commit SHA: pending. - -## Finding 2 — unbound loaded runtime and stdlib - -Reproduction tests: -`test_primary_qrf_worker_identity_binds_loaded_runtime_bytes` and -`test_primary_qrf_worker_identity_binds_imported_stdlib_source`. - -The four identity/resource reproduction nodes were run together: - -```sh -UV_CACHE_DIR=/private/tmp/microcosm-uv-cache uv run --no-sync pytest -q packages/microcosm-build/tests/test_us_stacked_spine.py::test_primary_qrf_worker_identity_binds_loaded_runtime_bytes packages/microcosm-build/tests/test_us_stacked_spine.py::test_primary_qrf_worker_identity_binds_imported_stdlib_source packages/microcosm-build/tests/test_us_stacked_spine.py::test_worker_identity_refuses_unapproved_torch_backend_provider_before_import packages/microcosm-build/tests/test_us_stacked_spine.py::test_worker_transitive_source_identity_binds_actual_imported_package_resource +$ git archive origin/main packages/microcosm-graph/src | tar -x -C $SCRATCH/mainsrc +$ uv run --no-sync python measure_keys.py # this branch +$ PYTHONPATH=$SCRATCH/mainsrc/packages/microcosm-graph/src \ + uv run --no-sync python measure_keys.py # origin/main ``` -Result: exit 1; 4 failed. For finding 2, changing the temporary runtime -library left both absent `runtime_binary` fields equal (`None == None`), and -changing the temporary imported `argparse.py` left both absent -`stdlib_imports_sha256` fields equal. - -Fix: pending. - -Commit SHA: pending. +Both exited 0. The runs loaded different `decl.py` bytes, confirming the shadow +took effect: `ed0a859a…` (this branch's locked hash) against +`635fef92…` (`origin/main`'s). The measurement was repeated at the final tree +after the audit fixes, with the same result. + +| Graph | Nodes | Keys identical | Projections identical | +| --- | ---: | --- | --- | +| `_toy.small_graph()` | 5 | yes | yes | +| `_toy.chained_graph()` | 5 | yes | yes | +| `_toy.chained_graph(leaves=("leaf_a",))` | 6 | yes | yes | +| `_toy.full_graph()` | 9 | yes | yes | +| `uk_spine_graph(load_country_spec("uk"))` | 41 | yes | yes | +| `us_post_transfer_graph()` | 8 | yes | yes | +| **total** | **74** | **74 / 74** | **74 / 74** | -## Finding 3 — Torch backend entry-point autoload - -Reproduction tests: -`test_worker_identity_refuses_unapproved_torch_backend_provider_before_import` -and `test_primary_qrf_worker_launch_forces_torch_backend_autoload_off`. - -The first ran in the four-node command above and failed because a synthetic, -unapproved distribution declaring a `torch.backends` entry point was accepted -(`DID NOT RAISE RuntimeError`). The launcher test ran separately: - -```sh -UV_CACHE_DIR=/private/tmp/microcosm-uv-cache uv run --no-sync pytest -q packages/microcosm-build/tests/test_puf_qrf_chain.py::test_primary_qrf_worker_launch_forces_torch_backend_autoload_off +``` +TOTAL node keys compared: 74 +node keys that moved: 0 +projections that moved: 0 ``` -Result: exit 1; 1 failed. With both the inherited and caller-supplied value set -to `1`, the child environment retained `TORCH_DEVICE_BACKEND_AUTOLOAD=1` -instead of forcing `0`. - -Fix: pending. - -Commit SHA: pending. - -## Finding 4 — incomplete import-time resource closure - -Reproduction test: -`test_worker_transitive_source_identity_binds_actual_imported_package_resource`. - -It ran in the four-node identity command above. Result: exit 1 as part of the -4-failure run. The manual two-file closure contained no row for -`soi_table_2_1_interest_components_ty2015.json` (`len(target_rows) == 0`). A -separate read-only audit-hook probe of a clean worker import found 24 Microcosm -non-code resources, including that asset. - -Fix: pending. - -Commit SHA: pending. - -## Pins moved - -Pending implementation and verification. - -## Final verification - -Pending. No final verification result is claimed yet. - -## Deliberately not done - -- No network access, push, branch creation, stash, artifact build, release, - publication, or graph-interface/acceptance-lock edit was performed. +The amendment entry's per-graph counts (5, 5, 6, 9, 41, 8; "74 node keys in +all") are each correct. + +### The converse, also measured + +Declaring an edge *does* move both ends, and the amendment says so rather than +claiming a free upgrade. Enforced by +`test_graph_keys.py::test_a_declared_artifact_edge_enters_both_ends_of_the_key`, +`::test_every_part_of_an_artifact_declaration_is_normative` (a type name, a +type version and the producer's output name move both ends; the consumer-local +alias moves only the consumer, because the producer never sees it), and +`::test_a_byte_edge_carries_descendant_exact_invalidation` (A3 holds over a +byte edge: re-hashing only the producer's implementation moves the consumer, +and without the declaration it does not). + +A given output therefore does **not** keep its store identity when a type is +declared for it — `opaque_artifact_key` is derived from the producing node's +key, which moved. The amendment entry and the `opaque_artifact_key` docstring +both say this explicitly. + +--- + +## 5. Commands run, with direct exit codes + +Every command was run from the worktree with `uv run --no-sync`, after +`uv sync --all-packages --locked --extra us --extra uk` (exit 0 in the landing +session; the environment was reused unchanged). **Every row below was re-run at +the final tree, after the audit fixes** — none is carried over from the landing +session, and none predates the last source change. + +| Command | Exit | Result | +| --- | ---: | --- | +| `pytest packages/microcosm-graph/tests` | **0** | **373 passed** | +| `pytest packages/microcosm-graph/tests -k acceptance` | **0** | 113 passed, 257 deselected | +| `pytest .../test_graph_kernel_contract.py` | **0** | 15 passed | +| `pytest` (every graph-dependent test outside the graph package) | **0** | **81 passed** | +| `pytest` (KernelContext consumers, 5 files) | **0** | 36 passed | +| `python tools/ci_test_groups.py --verify` | **0** | `verification=ok`, 388 tracked test files | +| `python tools/spec_engine_coverage.py --check` | **0** | 42156/42156 configuration fields; 41/41 inventory checks | +| `python tools/graph_acceptance_burndown.py --verify` | **0** | `verification=ok` | +| `ruff check .` | **0** | All checks passed | +| `ruff format --check` (the 17 changed `.py` files) | **0** | 17 files already formatted | +| `shasum -a 256` on the two frozen files | **0** | both match `docs/graph-interface.lock` | +| `git diff --stat origin/main...HEAD -- uv.lock` | **0** | empty | + +**The graph-dependent closure.** Rather than trusting that a change confined to +`packages/microcosm-graph/src` cannot reach elsewhere, the set was computed: +seven source modules outside the graph package import `microcosm.graph` +(`microcosm.build.{uk,us}_runtime.{graph,graph_kernels}`, +`microcosm.{calibrate,fit,frame}.kernels`), and 29 test files reach the graph +package directly or through one of those seven. Running all 29 — the whole +graph package plus the 8 outside it — is green: **373 + 81 passed, both exit +0.** That is every test in the workspace this change could affect. + +**The `KernelContext` consumers.** The narrower run the brief asks for is +`packages/microcosm-{calibrate,fit,frame}/tests/test_kernels.py` plus +`packages/microcosm-build/tests/test_{us,uk}_graph.py` — 36 passed, exit 0. A +grep of `KernelContext(` across `packages/`, `tools/` and `examples/` finds 17 +sites; the five outside `packages/microcosm-graph/tests` are the three +`test_kernels.py` fixtures and the executor's own two constructions, and +**every one of them passes keyword arguments** — no consumer constructs +`KernelContext` positionally, so the new field's placement could not break one. + +### Two commands that did not exit 0 + +**`ruff format --check .` → exit 1.** 81 of 825 files would be reformatted. +**None of them is touched by this lane** — the intersection of the 81 with +`git diff --name-only origin/main...HEAD` is empty, and all 17 changed Python +files pass `ruff format --check` individually (exit 0, above). This is +pre-existing repo drift; CI's `lint` lane runs only `ruff check .` +(`.github/workflows/test.yml:181`), which is green. + +**`pytest packages/microcosm-build/tests/test_release_target_parity.py` → +exit 1.** 2 failed, 35 passed: +`TestRegeneration::test_committed_artifacts_match_regeneration` and +`TestRegeneration::test_gate_passes_on_real_compiled_registry`, both raising +`LedgerHierarchyMetadataError: … dimension 'bea_nipa.series_code' requires +exactly one non-empty label, got []`. **Not this lane, and not a CI failure.** +Four pieces of evidence: + +1. Both tests are guarded by `TestRegeneration._feed_or_skip` + (`test_release_target_parity.py:484`), which skips when the pinned feed is + absent. That feed is `~/PolicyEngine/_buildh-runtime/inputs/consumer_facts_buildn_v9_4.jsonl` + — 131 MB, **outside the repository**, untracked, mtime **2026-07-23**. CI + does not have it, so CI skips both tests. +2. The July artifact predates #855's hierarchy-label requirement, which is + exactly the diagnosis the #791 lane already recorded for the UK twin + (`test_uk_target_references.py`) in + `experiments/791-household-composition-receipts.md:111`. +3. The same two tests fail identically with `origin/main`'s graph sources + shadowed in (exit 1, same two names), and + `microcosm/build/ledger_targets.py` imports no `microcosm.graph` at all. +4. `origin/main`'s 16 commits since this branch point (`3094bfe84..e6d362b7e`) + are all the #907 object-dtype storage-hashing lane; none touches + `ledger_targets.py`, `release_target_parity` or any Chronicle fact. + +--- + +## 6. The adversarial audit of the landed work + +The landing session ran a five-dimension adversarial review of the extraction +itself; seven of its nine findings were real and are among the commits in §1. +This session ran a *second*, independent audit of the finished result: five +auditors (extraction fidelity and minimality; charter mechanics; contract-test +coverage and red-first discipline; executor correctness; are the prose claims +true), each finding then put to two skeptics instructed to refute by default +and to reproduce rather than reason. + +**Nine findings raised. One survived both skeptics.** + +### The one that survived + +**The red commit `1cce8eceb` records "7 of 8 fail"; all 8 failed.** Two +skeptics independently reconstructed the tree at that commit and ran its own +test file against its own sources; I then reproduced it a third time: +`git archive 1cce8eceb | tar -x`, `PYTHONPATH` over that tree's six shard +`src` directories (confirmed by `microcosm.graph.executor` resolving into the +extract and `microcosm.graph.artifact_edges` having no spec there, so the +executor support genuinely had not landed yet), then the commit's own +`test_graph_executor.py`: **8 failed, 63 passed**, the 8 being exactly the +amendment-19 tests it added. + +Red-first discipline holds — the commit was *redder* than claimed — but the +tally in its message is wrong. `0c22b9d30` corrects the journal and says why +the commit message stays as it is: rewriting landed history to fix a tally +would be worse than recording the correction. + +### What the audit changed anyway + +One refuted finding still pointed at something worth fixing. A line trace of +the whole graph suite (`sys.settrace` pinned to the module) showed **eleven +non-docstring statements of `artifact_edges.py` never executed** — all of them +on the surface that parses provenance written elsewhere: +`value_from_descriptor`'s four refusals, `scope_from_payload`'s malformed-scope +and malformed-tolerance refusals plus its `Tolerance` reconstruction, and +`require_compatible_scope`'s platform-plus-tolerance refusal. + +The skeptics were right that the brief did not ask for this and that no latent +bug hid behind those lines — but the amendment says a manifest "authenticates +every edge on load", and those arms are what make that true against a foreign +manifest. `e3f69a4c4` adds three tests driving all of them through the public +`NodeReceipt` / `RunManifest` surface. The same trace now reports **zero** +unexercised statements in the module. + +The same finding's sharpest claim — that `run_graph`'s consumer-side +producer-receipt comparison (`executor.py:2059`) is never exercised — I +measured directly and confirmed in part: line 2059 runs on every +artifact-consuming node, but its `raise` arm never does. Both skeptics refuted +the finding correctly: the charter claims the *ordering of a check*, which does +execute. The arm is unreachable by construction — a producer that hit its +record had that identity checked by `_require_record_shape`, and one that ran +got it from `_write_node` under the same derivation — so `e3f69a4c4` marks it +with the comment convention `executor.py` already uses for such guards +(`# generated records cannot reach this branch`) rather than chasing it with a +test that cannot be written honestly. The misleading inline comment in +`test_a_cache_hit_authenticates_its_artifact_edges_without_reading_them`, which +implied the receipt comparison was the guard firing, now names the guard that +actually fires. + +### The seven refuted, and why + +| Finding | Why it was refuted | +| --- | --- | +| The node-key note says "six graphs" but `chained_graph()` with default args *is* `small_graph()` | True but not a defect: the text claims no distinctness, and the two are separate constructions whose keys were separately computed. A wording preference. | +| `PROGRESS.md` said "`ruff format --check` clean" | A skeptic extracted `origin/main` and got an identical set of 81 unformatted files, so the condition is pre-existing; and the journal already discloses the full precision forty lines below. | +| "Schema moves to 2 only for a node that declares artifacts" has no test | Both skeptics measured it rather than reading it: a plain node's record is written and accepted at schema 1, an artifact-declaring node's at 2. Code-true and checkable. | +| `explain.py`'s typed-artifact payload has no test | A skeptic executed the branch by hand (it emits `typed_artifacts` correctly) and found the identical untested-conditional pattern already on `origin/main` from `8549530c2`. | +| Two guards inside the required contract areas survive deletion | Reproduced by mutation — and the amendment-17 precedent the brief told this lane to mirror has exactly the same property on `origin/main`. | +| The producer-receipt refusal is never exercised | See above: the claim conflated "the raise arm is not taken" with "the check is not exercised". | +| A recomputed producer could hand a consumer an orphaned earlier payload | Real property of the store's write-once semantics (`_put` with `verify_existing=True` returns the existing object without comparing), but **byte-identical to `origin/main`** — this lane changed no `verify_existing` line — and reachable only if a kernel violates the determinism contract that makes same-node-key-same-bytes hold, in which case columns, frames and weights diverge the same way. | + +The fidelity dimension returned **zero** findings: it classified all 3,193 +diff lines of the integration branch's graph-src change, confirmed every +artifact-needed hunk landed and every excluded hunk is genuinely absent +(`grep` for `KEYED`, `keyed_uniform`, `population_retention`, +`_LazyPopulations`, `_stream_file`, `SourceBytesCodec` across the landed source +and tests returns nothing), and found no dangling dependency and no scope leak. + +--- + +## 7. Charter mechanics, item by item + +1. **Amendment entry 19** — `docs/graph-acceptance.md`, +85 lines, in the house + style of entries 1–18: what the gap was (byte dependencies had no channel + but undeclared `KernelResult.artifacts`, invisible to the compiler and + outside every key), what the new declarations are, which acceptance items + are touched (C3 "declared predecessors only" now covers bytes; B4's mutation + check covers artifacts; E1's payload validation; F2's gate ancestry; and + amendments 16 and 17's numeric classes), the one refused shape, the node-key + answer stated as measured with its six graphs and 74 keys, and the closing + *"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."* The + pre-existing paragraph about amendments 11 and 13 moving keys is extended + with one sentence saying this one does not. +2. **Lock re-recorded** — `docs/graph-interface.lock` now holds + `ed0a859a… decl.py` and `97c3ec9f… kernel.py`, both confirmed against + `shasum -a 256` of the current bytes (§5). +3. **Contract tests** — `test_graph_kernel_contract.py` +208, mirroring + amendment 17's shape in `cdbf71888`: + `test_artifact_value_validates_its_payload_type_and_identities`, + `test_context_artifacts_default_empty_and_are_immutable`, and + `test_artifact_declarations_round_trip_through_canonical_json`. +4. **Frozen acceptance suite, alone** — `a2b6dfb0b` is a one-file, one-line + commit adding `"artifacts"` to `test_acceptance_b_ownership.py`'s exact + `KernelContext` field set, with a trailing comment naming the amendment, + exactly mirroring `80b63ba14`'s shape for amendment 17. No other + `test_acceptance_*` file is touched anywhere on the branch. +5. **Changelog and exports** — one fragment, + `changelog.d/amend-typed-artifacts.added.md`, `added` being the correct + towncrier type for new interface; four names added to `graph/__init__.py`'s + imports and `__all__` in sorted position. +6. **Runs** — §5. + +--- + +## 8. Interim rulings and anything needing Max + +> Historicized 2026-09-12: this report was written before the branch was pushed +> as PR #911 (2026-09-11). The lock-enforcement test it names below merged as +> #910 on 2026-09-11, so "no automated enforcement" and "unmerged sibling +> branch" describe the state at writing time, not now. + +**One interim ruling this lane made.** A **gate kernel may not declare a typed +artifact output**, refused outright in `run_graph` before any node executes. A +gate whose kernel raises becomes a `fail` verdict and the run continues +(amendment 7), so its synthesized result carries no artifacts; a declared +output would turn that verdict into an aborted run. Amendment 19 carries no +regime for an output a node was unable to produce, nor for the consumers +thereby unreachable. Refusing the declaration keeps amendment 7 literally true +for every legal node shape, and leaves the design space open. If Max would +rather model it — an unproduced-output regime, with `blocked_by` semantics for +the consumers — that is a follow-up amendment, and it interacts with the +`availability.py` / execution-state lane this extraction deliberately left on +the integration branch. + +**The root cause of this lane, and the sibling branch that fixes it.** The +charter's "Interface freeze" section (`docs/graph-acceptance.md:153-159`) says +changing `decl.py` or `kernel.py` "requires the owner's sign-off on the pull +request and re-recording the lock" — a human gate. Nothing in the repository +compares the lock with the files: `grep` for `graph-interface` across the tree +finds only the charter sentence and the lock file itself, and CI's `lint` lane +runs only `ruff check .`. That is exactly how the integration branch extended +both frozen files with no amendment and no one noticing until this extraction. + +There is already an unmerged sibling branch for it — **`graph-interface-lock-test`, +one commit `8bd6e05ec` ("Enforce the graph interface lock with a test"), +branched from the same `3094bfe84`** — adding +`packages/microcosm-graph/tests/test_graph_interface_lock.py`, which asserts the +lock names exactly the two frozen files and that each one's SHA-256 matches. +**This branch already satisfies it:** its test file run against this tree exits +0, 2 passed (run from a temporary copy, removed afterwards; the tree is clean). +Merging that sibling would make the freeze enforceable instead of advisory, and +would have caught the integration branch at PR time. It is a governance change +— every future frozen-file edit becomes a hard CI failure until re-locked, +which is the point — so it is Max's call, not this lane's, and the two branches +do not conflict. + +**Two more, neither this lane's:** + +- `ruff format --check .` is red on 81 pre-existing files — a skeptic confirmed + the identical 81-file set on `origin/main`. CI does not gate on it, so it has + drifted silently. Worth one formatting sweep, on its own PR. +- The local `_buildh-runtime` feed artifact is from 2026-07-23 and now fails + the #855 hierarchy-label requirement, so two `test_release_target_parity.py` + tests are red on any machine that has it and green (skipped) everywhere else. + The UK twin was hit by the #791 lane in August. Re-pinning or refreshing that + local artifact would stop it ambushing future lanes' full-workspace runs. + +**No spec identity was re-pinned.** `tools/spec_engine_coverage.py --check` +passes unchanged at 42156/42156 and 41/41, so no drift arose to report. + +--- + +## 9. Deliberately not done + +No push, no PR, no branch created, no stash used, no `uv.lock` edit, no spec +re-pin, no artifact build, no release, no publication, and no acceptance-test +change beyond the single B2 field-set line the amendment requires. + + +> Historicized 2026-09-12: "no push, no PR" was true when written; the branch was pushed as PR #911 on 2026-09-11. diff --git a/packages/microcosm-graph/src/microcosm/graph/__init__.py b/packages/microcosm-graph/src/microcosm/graph/__init__.py index aaa781484..d755feaa3 100644 --- a/packages/microcosm-graph/src/microcosm/graph/__init__.py +++ b/packages/microcosm-graph/src/microcosm/graph/__init__.py @@ -16,6 +16,9 @@ PARTITION_DTYPES, ROWS_ALL, WEIGHT_KINDS, + ArtifactInput, + ArtifactOutput, + ArtifactType, CompiledGraph, Graph, GraphError, @@ -37,6 +40,7 @@ StoreUnavailableError, ) from .kernel import ( + ArtifactValue, Capabilities, Determinism, Kernel, @@ -62,6 +66,10 @@ "PARTITION_DTYPES", "ROWS_ALL", "WEIGHT_KINDS", + "ArtifactInput", + "ArtifactOutput", + "ArtifactType", + "ArtifactValue", "Capabilities", "CompiledGraph", "ContentStore", diff --git a/packages/microcosm-graph/src/microcosm/graph/artifact_edges.py b/packages/microcosm-graph/src/microcosm/graph/artifact_edges.py new file mode 100644 index 000000000..1d21c2406 --- /dev/null +++ b/packages/microcosm-graph/src/microcosm/graph/artifact_edges.py @@ -0,0 +1,210 @@ +"""Typed byte edges: numeric refusal, portable descriptors, and their values. + +The declaration side lives in :mod:`microcosm.graph.decl` (``ArtifactType``, +``ArtifactOutput``, ``ArtifactInput``) and the kernel side in +:mod:`microcosm.graph.kernel` (``ArtifactValue``). This module is the +executor-side glue between them: it derives the numeric scope a producer's +bytes carry, refuses an edge that would launder that scope, renders the +portable descriptor a receipt records, and rebuilds a verified +``ArtifactValue`` from one (amendment 19). +""" + +from __future__ import annotations + +from collections.abc import Mapping + +from .decl import ArtifactType, CompiledGraph, Node +from .errors import NodeRejectedError +from .kernel import ( + ArtifactValue, + Capabilities, + KernelRegistry, + Numeric, + NumericScope, + Tolerance, +) +from .keys import opaque_artifact_key, platform_fingerprint + +__all__ = [ + "descriptor", + "numeric_scope", + "require_compatible_scope", + "scope_from_payload", + "scope_payload", + "typed_contracts", + "value_from_descriptor", +] + + +def numeric_scope(capabilities: Capabilities) -> NumericScope: + """The scope a producer's bytes carry, from its declared capabilities.""" + + return NumericScope( + numeric=capabilities.numeric, + tolerance=capabilities.tolerance, + platform=platform_fingerprint() + if capabilities.numeric is Numeric.PLATFORM_BITWISE + else None, + ) + + +def scope_payload(scope: NumericScope) -> dict[str, object]: + """Render a scope as the canonical JSON a receipt records.""" + + return { + "numeric": scope.numeric.value, + "tolerance": None + if scope.tolerance is None + else { + "rtol": scope.tolerance.rtol, + "atol": scope.tolerance.atol, + "ulps": scope.tolerance.ulps, + }, + "platform": scope.platform, + } + + +def scope_from_payload(raw: object) -> NumericScope: + """Rebuild a scope from its recorded payload, refusing a malformed one.""" + + if not isinstance(raw, Mapping) or set(raw) != {"numeric", "tolerance", "platform"}: + raise ValueError("Malformed artifact numeric scope.") + tolerance = raw["tolerance"] + if tolerance is not None: + if not isinstance(tolerance, Mapping) or set(tolerance) != { + "rtol", + "atol", + "ulps", + }: + raise ValueError("Malformed artifact tolerance.") + tolerance = Tolerance(**tolerance) + return NumericScope( + Numeric(raw["numeric"]), tolerance=tolerance, platform=raw["platform"] + ) + + +def require_compatible_scope(scope: NumericScope, consumer: Capabilities) -> None: + """Refuse scope laundering across a byte edge. + + A cell's numeric class reaches its reader through + ``KernelContext.numerics`` (amendment 17), which a gate consults. Opaque + bytes carry no per-cell coordinates, so the only honest rule is that a + consumer of scoped bytes declares the same class: a platform-bitwise + payload may not be read by a kernel claiming to hold on every platform, + and a tolerance-bound payload may not be read by one claiming bitwise + reproduction. The consumer's own declared tolerance remains its own + output contract. + """ + + if scope.platform is not None and scope.numeric is Numeric.TOLERANCE_BOUND: + raise NodeRejectedError( + "Typed artifacts combining platform and tolerance scopes are unsupported." + ) + if ( + scope.numeric is Numeric.PLATFORM_BITWISE + and consumer.numeric is not Numeric.PLATFORM_BITWISE + ): + raise NodeRejectedError( + "A platform_bitwise artifact requires a platform_bitwise consumer." + ) + if ( + scope.numeric is Numeric.TOLERANCE_BOUND + and consumer.numeric is not Numeric.TOLERANCE_BOUND + ): + raise NodeRejectedError( + "A tolerance_bound artifact requires a tolerance_bound consumer." + ) + + +def descriptor( + *, + producer: str, + artifact: str, + type_: ArtifactType, + producer_key: str, + capabilities: Capabilities, +) -> dict[str, object]: + """The portable provenance of one typed artifact, as canonical JSON.""" + + return { + "producer": producer, + "artifact": artifact, + "producer_key": producer_key, + "key": opaque_artifact_key(producer_key, artifact), + "type": {"name": type_.name, "schema_version": type_.schema_version}, + "numerics": scope_payload(numeric_scope(capabilities)), + } + + +def typed_contracts( + compiled: CompiledGraph, + node: Node, + keys: Mapping[str, str], + kernels: KernelRegistry, +) -> dict[str, object]: + """Every typed edge of one node, resolved against the compiled graph. + + Empty for a node that declares none, so nothing is recorded and no + cached record shape changes for a graph that predates amendment 19. + """ + + if not node.artifact_inputs and not node.artifact_outputs: + return {} + consumer = kernels.get(node.kernel).capabilities + inputs = {} + for binding in node.artifact_inputs: + producer = compiled.graph.node(binding.producer) + capabilities = kernels.get(producer.kernel).capabilities + require_compatible_scope(numeric_scope(capabilities), consumer) + inputs[binding.name] = descriptor( + producer=producer.id, + artifact=binding.artifact, + type_=binding.type, + producer_key=keys[producer.id], + capabilities=capabilities, + ) + return { + "inputs": inputs, + "outputs": { + output.name: descriptor( + producer=node.id, + artifact=output.name, + type_=output.type, + producer_key=keys[node.id], + capabilities=consumer, + ) + for output in node.artifact_outputs + }, + } + + +def value_from_descriptor(payload: bytes, raw: object) -> ArtifactValue: + """Rebuild a verified :class:`ArtifactValue` from a recorded descriptor.""" + + if not isinstance(raw, Mapping) or set(raw) != { + "producer", + "artifact", + "producer_key", + "key", + "type", + "numerics", + }: + raise ValueError("Malformed typed artifact descriptor.") + if any( + not isinstance(raw[name], str) or not raw[name] + for name in ("producer", "artifact") + ): + raise ValueError("Typed artifact producer/name must be nonempty strings.") + type_raw = raw["type"] + if not isinstance(type_raw, Mapping) or set(type_raw) != {"name", "schema_version"}: + raise ValueError("Malformed typed artifact type.") + value = ArtifactValue( + payload, + ArtifactType(type_raw["name"], type_raw["schema_version"]), + raw["key"], + raw["producer_key"], + scope_from_payload(raw["numerics"]), + ) + if value.key != opaque_artifact_key(value.producer_key, raw["artifact"]): + raise ValueError("Typed artifact identity does not match its producer.") + return value diff --git a/packages/microcosm-graph/src/microcosm/graph/decl.py b/packages/microcosm-graph/src/microcosm/graph/decl.py index b25e4f621..16b2ba0e2 100644 --- a/packages/microcosm-graph/src/microcosm/graph/decl.py +++ b/packages/microcosm-graph/src/microcosm/graph/decl.py @@ -48,6 +48,9 @@ from types import MappingProxyType __all__ = [ + "ArtifactInput", + "ArtifactOutput", + "ArtifactType", "DESCRIPTIVE_FIELDS", "DTYPES", "GATE_OUTCOMES", @@ -175,6 +178,79 @@ def __post_init__(self) -> None: _nonempty("SourceRef.codec", self.codec) +@dataclass(frozen=True) +class ArtifactType: + """A nominal, versioned contract over an opaque byte payload. + + The graph never parses the bytes: it carries the name and version a + producer declared and refuses an edge whose consumer expected another + pair. Validating the payload itself is the consumer's job (amendment + 19). + + Attributes: + name: The contract's name, e.g. ``"qrf.forest"``. + schema_version: A positive integer; a payload change bumps it. + """ + + name: str + schema_version: int + + def __post_init__(self) -> None: + _nonempty("ArtifactType.name", self.name) + if type(self.schema_version) is not int or self.schema_version < 1: + raise GraphError("ArtifactType.schema_version must be a positive integer.") + + +@dataclass(frozen=True) +class ArtifactOutput: + """A named, typed subset of a kernel's opaque byte outputs. + + The bytes still arrive in ``KernelResult.artifacts`` under ``name``; the + declaration is what makes the output addressable by another node and + required of the kernel. Undeclared diagnostic bytes remain legal. + + Attributes: + name: The name the kernel returns the bytes under. + type: The nominal contract the payload claims to satisfy. + """ + + name: str + type: ArtifactType + + def __post_init__(self) -> None: + _nonempty("ArtifactOutput.name", self.name) + if not isinstance(self.type, ArtifactType): + raise GraphError("ArtifactOutput.type must be an ArtifactType.") + + +@dataclass(frozen=True) +class ArtifactInput: + """A declared artifact edge, with a consumer-local alias and exact type. + + The consumer reads the bytes under its own ``name``, so a node is not + coupled to what the producer called them. ``compile_graph`` resolves the + edge and refuses an unknown producer, an undeclared output, or a type + the producer does not declare. + + Attributes: + name: The alias the consumer reads in ``KernelContext.artifacts``. + producer: The node id that declares the output. + artifact: The producer's declared output name. + type: The exact :class:`ArtifactType` the producer declares. + """ + + name: str + producer: str + artifact: str + type: ArtifactType + + def __post_init__(self) -> None: + for field_name in ("name", "producer", "artifact"): + _nonempty(f"ArtifactInput.{field_name}", getattr(self, field_name)) + if not isinstance(self.type, ArtifactType): + raise GraphError("ArtifactInput.type must be an ArtifactType.") + + @dataclass(frozen=True) class Slice: """What a node reads: columns of one entity, optionally under a row mask. @@ -279,6 +355,13 @@ class Node: Attributes: id: Unique within the graph. kernel: Kernel reference, e.g. ``"fit.qrf@1"``. + artifact_inputs: Typed byte dependencies on other nodes. Each local + alias names a declared producer output of exactly the expected + nominal type and version; the producer becomes a predecessor + without owning any cell of this node (amendment 19). + artifact_outputs: Typed byte outputs the kernel must return in + ``KernelResult.artifacts``. Other untyped diagnostic bytes + remain legal and stay unaddressable. inputs: Slices the kernel receives. Their owners are this node's predecessors. outputs: Cells this node owns. A ``CREATE`` node declares every @@ -318,10 +401,25 @@ class Node: description: str = "" citation: str = "" entrants: bool = False + artifact_inputs: tuple[ArtifactInput, ...] = () + artifact_outputs: tuple[ArtifactOutput, ...] = () def __post_init__(self) -> None: _nonempty("Node.id", self.id) _nonempty("Node.kernel", self.kernel) + for name, kind in ( + ("artifact_inputs", ArtifactInput), + ("artifact_outputs", ArtifactOutput), + ): + declarations = getattr(self, name) + if not isinstance(declarations, tuple) or any( + not isinstance(item, kind) for item in declarations + ): + raise GraphError( + f"Node {self.id!r}: {name} must be a tuple of {kind.__name__}." + ) + if len({item.name for item in declarations}) != len(declarations): + raise GraphError(f"Node {self.id!r}: duplicate names in {name}.") if not isinstance(self.structural, StructuralDelta): raise GraphError(f"Node {self.id!r}: structural must be a StructuralDelta.") if self.mass not in MASS_POLICIES: @@ -412,11 +510,20 @@ def __post_init__(self) -> None: ) def normative(self) -> dict[str, object]: - """The projection that enters the node key (descriptive fields dropped).""" + """The projection that enters the node key (descriptive fields dropped). + + The artifact declarations are normative but elided when empty, so a + node that declares none projects exactly as it did before amendment + 19 and its key does not move. + """ return { f.name: getattr(self, f.name) for f in fields(self) if f.name not in DESCRIPTIVE_FIELDS + and not ( + f.name in {"artifact_inputs", "artifact_outputs"} + and not getattr(self, f.name) + ) } @@ -678,6 +785,34 @@ def check_mask(node_id: str, version: str, entity: str, mask: str) -> None: f"but the incumbent is declared {base_dtype!r}." ) + # Artifact edges cross population versions, without changing cell ownership + # (amendment 19). They join the same predecessor sets, so the depth + # computation below refuses an artifact cycle like any other. + for node in graph.nodes: + for binding in node.artifact_inputs: + if binding.producer == node.id: + raise GraphError( + f"Node {node.id!r} depends on itself through an artifact." + ) + producer = by_id.get(binding.producer) + if producer is None: + raise GraphError( + f"Node {node.id!r}: unknown artifact producer {binding.producer!r}." + ) + outputs = {output.name: output for output in producer.artifact_outputs} + output = outputs.get(binding.artifact) + if output is None: + raise GraphError( + f"Node {node.id!r}: producer {producer.id!r} has no declared " + f"artifact {binding.artifact!r}." + ) + if output.type != binding.type: + raise GraphError( + f"Node {node.id!r}: artifact {binding.artifact!r} type does not " + "match its producer." + ) + predecessors[node.id].add(producer.id) + depth: dict[str, int] = {} def depth_of(node_id: str, trail: tuple[str, ...]) -> int: diff --git a/packages/microcosm-graph/src/microcosm/graph/executor.py b/packages/microcosm-graph/src/microcosm/graph/executor.py index 689c1cf9a..10d3488cf 100644 --- a/packages/microcosm-graph/src/microcosm/graph/executor.py +++ b/packages/microcosm-graph/src/microcosm/graph/executor.py @@ -17,6 +17,7 @@ from microcosm.frame import Frame, WeightKind, Weights from . import keys as graph_keys +from .artifact_edges import scope_payload, typed_contracts, value_from_descriptor from .canonical import canonical_json, sha256_domain from .codecs import SOURCE_CODECS, SourceCodecRegistry from .decl import ( @@ -30,6 +31,7 @@ ) from .errors import NodeRejectedError from .kernel import ( + ArtifactValue, Capabilities, KernelContext, KernelRegistry, @@ -87,7 +89,7 @@ def _cache_record_key(key: str) -> str: def _opaque_artifact_key(key: str, name: str) -> str: - return sha256_domain("node-artifact", canonical_json((key, name))) + return graph_keys.opaque_artifact_key(key, name) def _normal_json_mapping(value: Mapping[str, object], label: str) -> dict[str, object]: @@ -386,6 +388,21 @@ def _context_digest(context: KernelContext) -> bytes: digest.update(weights.kind.value.encode("ascii") + b"\0") _update_array(digest, weights.values) _update_series(digest, context.strata) + for name, value in sorted(context.artifacts.items()): + digest.update( + canonical_json( + ( + name, + value.key, + value.producer_key, + value.type.name, + value.type.schema_version, + scope_payload(value.numerics), + ) + ) + ) + digest.update(len(value.payload).to_bytes(8, "little")) + digest.update(value.payload) return digest.digest() @@ -461,6 +478,7 @@ def _project_context( sources: Mapping[str, Path], tolerances: Mapping[tuple[str, str], Tolerance | None], numerics: Mapping[tuple[str, str], NumericScope], + artifacts: Mapping[str, ArtifactValue] | None = None, ) -> KernelContext: if population is None: return KernelContext( @@ -471,6 +489,7 @@ def _project_context( params=node.params, rng=np.random.default_rng(seed(key)), sources=MappingProxyType({name: sources[name] for name in node.sources}), + artifacts={} if artifacts is None else artifacts, tolerances=tolerances, numerics=numerics, ) @@ -562,6 +581,7 @@ def _project_context( params=node.params, rng=np.random.default_rng(seed(key)), sources=MappingProxyType({name: sources[name] for name in node.sources}), + artifacts={} if artifacts is None else artifacts, tolerances=tolerances, numerics=numerics, ) @@ -1126,6 +1146,17 @@ def _validate_result( if not isinstance(payload, bytes): raise NodeRejected(f"Node {node.id!r} artifact {name!r} is not bytes.") artifacts[name] = payload + for output in node.artifact_outputs: + if output.name not in artifacts: + # A cached record that lacks a declared artifact is a miss, but + # that decision belongs to `_require_record_shape`, which runs + # inside the miss-to-recompute fallback. By the time a restored + # result reaches here the record has already been accepted, so a + # still-absent artifact is corruption, not a miss. + error = StoreCorrupt if cache_hit else NodeRejected + raise error( + f"Node {node.id!r} is missing declared artifact {output.name!r}." + ) receipt = _normal_json_mapping(result.receipt, f"Node {node.id!r} receipt") if node.structural is StructuralDelta.EXPAND: if cache_hit: @@ -1378,6 +1409,7 @@ def _write_node( receipt: Mapping[str, object], opaque_artifacts: Mapping[str, bytes], verify_existing: bool, + typed_artifacts: Mapping[str, object] | None = None, ) -> tuple[dict[tuple[str, str], str], dict[str, object]]: columns: dict[tuple[str, str], tuple[pd.Series, str]] = {} if node.structural is StructuralDelta.NONE: @@ -1462,7 +1494,11 @@ def _write_node( opaque_entries.append({"name": name, "key": output_key}) record: dict[str, object] = { - "schema_version": 1, + # Schema 2 only when the node declares typed artifacts, so a graph + # that predates amendment 19 keeps its schema-1 records and its + # store hits. + "schema_version": 2 if typed_artifacts else 1, + **({"typed_artifacts": dict(typed_artifacts)} if typed_artifacts else {}), "node_id": node.id, "node_key": key, "kernel_ref": node.kernel, @@ -1490,6 +1526,7 @@ def _require_record_shape( key: str, kernel_impl_hash: str, capabilities: Capabilities, + typed_artifacts: Mapping[str, object] | None = None, ) -> dict[str, object]: if not isinstance(raw, dict): raise StoreCorrupt(f"Cached receipt for node {node.id!r} is not an object.") @@ -1506,16 +1543,39 @@ def _require_record_shape( "weight", "opaque", } + if typed_artifacts: + required.add("typed_artifacts") if set(raw) != required: raise StoreCorrupt( f"Cached receipt for node {node.id!r} has fields {sorted(raw)}, " f"not {sorted(required)}." ) - if raw["schema_version"] != 1: + if raw["schema_version"] != (2 if typed_artifacts else 1): raise StoreUnavailable( f"Cached receipt for node {node.id!r} uses unsupported schema " f"{raw['schema_version']!r}." ) + if typed_artifacts: + if raw.get("typed_artifacts") != dict(typed_artifacts): + raise StoreCorrupt( + f"Cached node {node.id!r} typed artifact contracts disagree with " + "the graph." + ) + opaque = _record_entries(raw, "opaque") + names = [entry.get("name") for entry in opaque] + if len(set(names)) != len(names): + raise StoreCorrupt(f"Cached node {node.id!r} repeats an opaque artifact.") + actual_outputs = {entry.get("name"): entry.get("key") for entry in opaque} + for output in node.artifact_outputs: + if output.name not in actual_outputs: + raise StoreMiss( + f"Cached node {node.id!r} is missing declared artifact " + f"{output.name!r}." + ) + if actual_outputs[output.name] != _opaque_artifact_key(key, output.name): + raise StoreCorrupt( + f"Cached node {node.id!r} artifact identity mismatch." + ) expected = (node.id, key, node.kernel, kernel_impl_hash) actual = ( raw["node_id"], @@ -1581,6 +1641,7 @@ def _load_record( key: str, kernel_impl_hash: str, capabilities: Capabilities, + typed_artifacts: Mapping[str, object] | None = None, ) -> dict[str, object]: raw = store.load_json(_cache_record_key(key)) return _require_record_shape( @@ -1589,6 +1650,7 @@ def _load_record( key=key, kernel_impl_hash=kernel_impl_hash, capabilities=capabilities, + typed_artifacts=typed_artifacts, ) @@ -1867,6 +1929,7 @@ def _preflight_require( key=keys[node_id], kernel_impl_hash=implementations[node_id], capabilities=kernels.get(node.kernel).capabilities, + typed_artifacts=typed_contracts(compiled, node, keys, kernels), ) _require_tolerance_writer_receipt( node, @@ -1926,6 +1989,25 @@ def run_graph( started_at = _now() source_paths, source_keys = _source_paths_and_keys(compiled, sources, store) keys, implementations = _all_node_keys(compiled, kernels, source_keys) + contracts = { + node_id: typed_contracts(compiled, compiled.graph.node(node_id), keys, kernels) + for node_id in compiled.order + } + for node_id in compiled.order: + node = compiled.graph.node(node_id) + # A gate whose kernel raises becomes a `fail` verdict and the run + # continues (amendment 7), so its synthesized result carries no + # artifacts. Amendment 19 has no regime for an output a node was + # unable to produce, so a gate that declares one is refused rather + # than allowed to turn a verdict into an aborted run. + if node.artifact_outputs and ( + kernels.get(node.kernel).capabilities.role is KernelRole.GATE + ): + raise NodeRejected( + f"Node {node_id!r}: a gate kernel may not declare a typed artifact " + "output, because a gate exception is a verdict and would leave the " + "output unproduced." + ) if resume == "require": _preflight_require(compiled, store, keys, implementations, kernels) @@ -1965,6 +2047,28 @@ def run_graph( ) tolerance_writers = _tolerance_writer_payload(input_writers) + typed = contracts[node_id] + # Authenticate every declared byte edge against the producer's receipt + # and its descriptor now, without reading a payload: identity is all + # this check needs, and a node that hits its cached record never runs a + # kernel, so its inputs' bytes would be read for nothing (a fitted + # model is not small). + for binding in node.artifact_inputs: + entry = typed["inputs"][binding.name] + producer_receipt = receipts[binding.producer] + if producer_receipt.opaque_artifacts.get(binding.artifact) != entry["key"]: + # Generated receipts cannot reach this branch: a producer that + # hit its record had this identity checked by + # `_require_record_shape`, and one that ran got it from + # `_write_node` under the same derivation. It stands so the + # consumer's read is guarded by its own check rather than by + # the producer's, should those two paths ever diverge. + raise StoreCorrupt( + f"Node {node.id!r} artifact producer receipt disagrees with its " + "declaration." + ) + value_from_descriptor(b"", entry) + hit = False replace_stale_record = False result: KernelResult | None = None @@ -1978,6 +2082,7 @@ def run_graph( key=key, kernel_impl_hash=implementation, capabilities=kernel.capabilities, + typed_artifacts=typed, ) try: _require_tolerance_writer_receipt( @@ -1998,6 +2103,13 @@ def run_graph( raise if result is None: + artifact_values: dict[str, ArtifactValue] = { + binding.name: value_from_descriptor( + store.load_bytes(typed["inputs"][binding.name]["key"]), + typed["inputs"][binding.name], + ) + for binding in node.artifact_inputs + } context = _project_context( node, incumbent, @@ -2005,6 +2117,7 @@ def run_graph( sources=source_paths, tolerances=input_tolerances, numerics=input_numerics, + artifacts=artifact_values, ) before = _context_digest(context) try: @@ -2149,6 +2262,7 @@ def run_graph( receipt=cache_receipt, opaque_artifacts=opaque, verify_existing=(resume != "forbid" and not replace_stale_record), + typed_artifacts=typed, ) assert record is not None @@ -2172,6 +2286,7 @@ def run_graph( receipt_opaque[name] = artifact_identity receipts[node_id] = NodeReceipt( + typed_artifacts=typed, key=key, hit=hit, seed=seed(key), diff --git a/packages/microcosm-graph/src/microcosm/graph/explain.py b/packages/microcosm-graph/src/microcosm/graph/explain.py index 9d10d8be6..26abfdbd4 100644 --- a/packages/microcosm-graph/src/microcosm/graph/explain.py +++ b/packages/microcosm-graph/src/microcosm/graph/explain.py @@ -506,6 +506,11 @@ def _receipt_payload(receipt: NodeReceipt) -> dict[str, object]: "frame_key": receipt.frame_key, "weight_key": receipt.weight_key, "opaque_artifacts": receipt.opaque_artifacts, + **( + {"typed_artifacts": receipt.typed_artifacts} + if receipt.typed_artifacts + else {} + ), "wall_time": receipt.wall_time, } if receipt.legacy_capabilities: diff --git a/packages/microcosm-graph/src/microcosm/graph/kernel.py b/packages/microcosm-graph/src/microcosm/graph/kernel.py index 567a57ea5..0a7c95d01 100644 --- a/packages/microcosm-graph/src/microcosm/graph/kernel.py +++ b/packages/microcosm-graph/src/microcosm/graph/kernel.py @@ -59,9 +59,10 @@ from microcosm.frame import Frame, Weights -from .decl import Node, Param, StructuralDelta +from .decl import ArtifactType, Node, Param, StructuralDelta __all__ = [ + "ArtifactValue", "Capabilities", "Determinism", "Kernel", @@ -270,6 +271,55 @@ def __post_init__(self) -> None: raise ValueError("A bitwise scope holds on every platform.") +@dataclass(frozen=True) +class ArtifactValue: + """Verified immutable bytes and the executor's typed producer provenance. + + A consumer reads one of these per declared :class:`~.decl.ArtifactInput` + alias. The executor has already checked that the bytes are the ones the + named producer stored under that output name, so ``key`` and + ``producer_key`` are the identities a receipt can be audited against. + Validating the payload against its nominal ``type`` is the consumer's + own job: the graph carries a name and version, not a parser (amendment + 19). + + Attributes: + payload: The stored bytes, immutable. + type: The nominal contract the producer declared. + key: The artifact's store identity, derived from the producing + node's key and the output name (``opaque_artifact_key``). It is + not a hash of ``payload``: the store validates the bytes filed + under it against their own recorded SHA-256 on every load + (charter E1), and the executor checks this key against the + producer's receipt before handing the value over. + producer_key: The node key of the node that produced it. + numerics: The producer's :class:`NumericScope`, so a gate reading an + artifact holds it to the same contract as a cell (amendment 17). + """ + + payload: bytes + type: ArtifactType + key: str + producer_key: str + numerics: NumericScope + + def __post_init__(self) -> None: + if not isinstance(self.payload, bytes): + raise TypeError("ArtifactValue.payload must be immutable bytes.") + if not isinstance(self.type, ArtifactType) or not isinstance( + self.numerics, NumericScope + ): + raise TypeError("ArtifactValue requires an ArtifactType and NumericScope.") + for name in ("key", "producer_key"): + value = getattr(self, name) + if ( + not isinstance(value, str) + or len(value) != 64 + or any(c not in "0123456789abcdef" for c in value) + ): + raise ValueError(f"ArtifactValue.{name} must be a SHA-256 identity.") + + @dataclass(frozen=True) class KernelContext: """Everything a kernel may read. Nothing here is writable. @@ -290,6 +340,12 @@ class KernelContext: kernel may use. sources: Source name to a content-verified path, for declared sources only. + artifacts: Declared artifact alias to its :class:`ArtifactValue`, + for the node's ``artifact_inputs`` only. Immutable typed bytes + the executor has already matched to their producer; a consumer + validates the versioned payload before using it, because a + nominal type does not itself verify serialized data (amendment + 19). tolerances: ``(entity, column)`` of each declared input column to the :class:`Tolerance` its owning kernel declared, or ``None`` for a bitwise owner. A gate compares against these. @@ -307,9 +363,23 @@ class KernelContext: params: Mapping[str, Param] rng: np.random.Generator sources: Mapping[str, Path] = field(default_factory=dict) + artifacts: Mapping[str, ArtifactValue] = field(default_factory=dict) tolerances: Mapping[tuple[str, str], Tolerance | None] = field(default_factory=dict) numerics: Mapping[tuple[str, str], NumericScope] = field(default_factory=dict) + def __post_init__(self) -> None: + values = dict(self.artifacts) + if any( + not isinstance(name, str) + or not name + or not isinstance(value, ArtifactValue) + for name, value in values.items() + ): + raise TypeError( + "KernelContext.artifacts must map non-empty aliases to ArtifactValue." + ) + object.__setattr__(self, "artifacts", MappingProxyType(values)) + @dataclass(frozen=True) class KernelResult: diff --git a/packages/microcosm-graph/src/microcosm/graph/keys.py b/packages/microcosm-graph/src/microcosm/graph/keys.py index 43939dbae..e9cc65e2f 100644 --- a/packages/microcosm-graph/src/microcosm/graph/keys.py +++ b/packages/microcosm-graph/src/microcosm/graph/keys.py @@ -13,6 +13,7 @@ from .kernel import Capabilities, Numeric __all__ = [ + "opaque_artifact_key", "platform_fingerprint", "artifact_key", "frame_key", @@ -88,6 +89,20 @@ def artifact_key(node_key: str, entity: str, column: str) -> str: return _hash_parts("artifact", node_key, entity, column) +def opaque_artifact_key(node_key: str, name: str) -> str: + """Identity of an opaque or typed byte output of a node (amendment 19). + + Typed and undeclared outputs share one derivation — the domain and + formula the executor already used for undeclared opaque artifacts — so + amendment 19 introduces no second identity scheme. It does not follow + that an existing output keeps its identity when a type is declared for + it: ``artifact_outputs`` is normative, so declaring one moves the + producing node's key, and this identity moves with it. + """ + + return _hash_parts("node-artifact", node_key, name) + + def frame_key(node_key: str) -> str: """Derive the structural frame artifact identity from its node.""" @@ -240,6 +255,31 @@ def node_key( if kernel_capabilities.numeric is Numeric.PLATFORM_BITWISE else () ) + # A declared artifact edge binds the consumer to the producer's exact + # bytes: the producer's node key resolves to the artifact identity the + # executor will load (amendment 19). A node declaring none adds no term, + # so its key is unchanged. + typed_inputs = ( + ( + { + "typed_artifacts": tuple( + ( + item.name, + opaque_artifact_key( + _required_key(input_keys, item.producer, node_id), + item.artifact, + ), + normative(item.type), + ) + for item in sorted( + node.artifact_inputs, key=lambda value: value.name + ) + ) + }, + ) + if node.artifact_inputs + else () + ) return _hash_parts( "node", normative(node), @@ -250,6 +290,7 @@ def node_key( graph_facts, capabilities, *platform_scope, + *typed_inputs, ) diff --git a/packages/microcosm-graph/src/microcosm/graph/manifest.py b/packages/microcosm-graph/src/microcosm/graph/manifest.py index 90b4fa3bc..e61680bf2 100644 --- a/packages/microcosm-graph/src/microcosm/graph/manifest.py +++ b/packages/microcosm-graph/src/microcosm/graph/manifest.py @@ -13,6 +13,7 @@ from microcosm.frame import Frame +from .artifact_edges import require_compatible_scope, value_from_descriptor from .canonical import canonical_json, sha256_domain from .decl import GATE_OUTCOMES, StructuralDelta from .errors import NodeRejectedError, StoreCorruptError @@ -34,6 +35,7 @@ __all__ = ["Decision", "NodeReceipt", "PopulationView", "RunManifest"] _SCHEMA_VERSION = 2 +_TYPED_SCHEMA_VERSION = 3 _LEGACY_SCHEMA_VERSION = 1 _CERTIFYING_GATE_OUTCOMES = frozenset({"pass", "not_applicable"}) @@ -220,6 +222,7 @@ class NodeReceipt: weight_key: str | None = None opaque_artifacts: Mapping[str, str] = field(default_factory=dict) legacy_capabilities: bool = field(default=False, kw_only=True) + typed_artifacts: Mapping[str, object] = field(default_factory=dict, kw_only=True) def __post_init__(self) -> None: if not isinstance(self.key, str): @@ -244,6 +247,26 @@ def __post_init__(self) -> None: object.__setattr__(self, "hit", False) elif not isinstance(self.capabilities, Capabilities): raise TypeError("NodeReceipt.capabilities must be Capabilities") + typed = _freeze_json(self.typed_artifacts) + if not isinstance(typed, Mapping): + raise TypeError("NodeReceipt.typed_artifacts must be a mapping.") + if typed: + if set(typed) != {"inputs", "outputs"} or any( + not isinstance(typed[name], Mapping) for name in typed + ): + raise ValueError( + "Typed artifact provenance requires input/output mappings." + ) + for bindings in typed.values(): + for alias, entry in bindings.items(): + if not isinstance(alias, str) or not alias: + raise ValueError( + "Typed artifact aliases must be nonempty strings." + ) + value_from_descriptor(b"", entry) + if self.legacy_capabilities: + raise ValueError("Legacy capabilities cannot describe typed artifacts.") + object.__setattr__(self, "typed_artifacts", typed) frozen_receipt = _freeze_json(self.receipt) if not isinstance(frozen_receipt, Mapping): raise TypeError("NodeReceipt.receipt must be a mapping") @@ -347,6 +370,11 @@ def _payload(self) -> dict[str, object]: "frame_key": self.frame_key, "weight_key": self.weight_key, "opaque_artifacts": self.opaque_artifacts, + **( + {"typed_artifacts": self.typed_artifacts} + if self.typed_artifacts + else {} + ), "wall_time": self.wall_time, } @@ -419,6 +447,7 @@ def __post_init__(self) -> None: raise TypeError("RunManifest.nodes values must be NodeReceipt") nodes[node_id] = receipt object.__setattr__(self, "nodes", MappingProxyType(nodes)) + _validate_typed_ancestry(nodes) decisions = tuple( decision @@ -592,7 +621,9 @@ def to_json(self) -> str: """Serialize the complete portable provenance as canonical JSON.""" payload = { - "schema_version": _SCHEMA_VERSION, + "schema_version": _TYPED_SCHEMA_VERSION + if any(node.typed_artifacts for node in self.nodes.values()) + else _SCHEMA_VERSION, "key": self.key, "tier": self.tier, "known_failures": self.known_failures, @@ -633,6 +664,7 @@ def from_json(cls, value: str | bytes | bytearray) -> Self: if type(schema_version) is not int or schema_version not in { _LEGACY_SCHEMA_VERSION, _SCHEMA_VERSION, + _TYPED_SCHEMA_VERSION, }: raise ValueError(f"unsupported manifest schema version {schema_version!r}") @@ -657,6 +689,10 @@ def from_json(cls, value: str | bytes | bytearray) -> Self: finished_at=_string_field(raw, "finished_at"), host=_string_field(raw, "host"), ) + if schema_version == _TYPED_SCHEMA_VERSION and not any( + node.typed_artifacts for node in nodes.values() + ): + raise ValueError("Schema-v3 manifest must carry typed artifact provenance.") body = raw.get("content_addressed") if not isinstance(body, Mapping): raise ValueError("manifest content-addressed body must be an object") @@ -678,7 +714,7 @@ def from_json(cls, value: str | bytes | bytearray) -> Self: raise ValueError( "manifest content key mismatch: serialized provenance was altered" ) - if schema_version == _SCHEMA_VERSION and serialized_key != manifest.key: + if schema_version != _LEGACY_SCHEMA_VERSION and serialized_key != manifest.key: raise ValueError( "manifest content key mismatch: serialized key differs from " "reconstructed portable provenance" @@ -1090,6 +1126,9 @@ def _node_receipt_from_payload(value: object, *, schema_version: int) -> NodeRec frame_key = value.get("frame_key") weight_key = value.get("weight_key") opaque_artifacts = value.get("opaque_artifacts", {}) + typed_artifacts = value.get("typed_artifacts", {}) + if "typed_artifacts" in value and schema_version != _TYPED_SCHEMA_VERSION: + raise ValueError("Typed artifact provenance requires manifest schema 3.") capabilities_payload = value.get("capabilities") if schema_version == _LEGACY_SCHEMA_VERSION: # Every schema-v1 receipt is legacy: v1 never recorded a tolerance, so @@ -1163,4 +1202,110 @@ def _node_receipt_from_payload(value: object, *, schema_version: int) -> NodeRec weight_key=weight_key, opaque_artifacts=opaque_artifacts, legacy_capabilities=legacy_capabilities, + typed_artifacts=typed_artifacts, ) + + +def _validate_typed_ancestry(nodes: Mapping[str, NodeReceipt]) -> None: + """Authenticate every typed artifact edge recorded in a manifest. + + A descriptor is portable provenance, so a manifest must stand on its own: + each recorded output names its own node and key, each recorded input + resolves to a producer whose output descriptor is byte-identical, the + producer's numeric scope is the one its capabilities declare and one the + consumer may read, and the edges form a DAG. A release's gate ancestry + covers gates reached through byte edges too, so amendment 19 cannot open + a path around charter F2 (amendment 19). + """ + + edges: dict[str, set[str]] = {node_id: set() for node_id in nodes} + for node_id, node in nodes.items(): + if not node.typed_artifacts: + continue + for name, entry in node.typed_artifacts["outputs"].items(): + value = value_from_descriptor(b"", entry) + if ( + entry["producer"] != node_id + or entry["artifact"] != name + or value.producer_key != node.key + or node.opaque_artifacts.get(name) != value.key + ): + raise ValueError( + f"Node {node_id!r} typed artifact output provenance mismatch." + ) + if ( + value.numerics.numeric is not node.capabilities.numeric + or value.numerics.tolerance != node.capabilities.tolerance + ): + raise ValueError( + f"Node {node_id!r} typed artifact numeric contract mismatch." + ) + for entry in node.typed_artifacts["inputs"].values(): + value = value_from_descriptor(b"", entry) + producer_id = entry["producer"] + producer = nodes.get(producer_id) + if producer is None or producer.key != value.producer_key: + raise ValueError( + f"Node {node_id!r} typed artifact producer is missing or " + "inconsistent." + ) + if ( + producer.typed_artifacts.get("outputs", {}).get(entry["artifact"]) + != entry + ): + raise ValueError( + f"Node {node_id!r} typed artifact does not match its producer " + "output." + ) + try: + require_compatible_scope(value.numerics, node.capabilities) + except NodeRejectedError as error: + # Inside a manifest this is malformed provenance, not a live + # node rejection: `RunManifest.load` converts ValueError into + # StoreCorruptError, and a corrupt manifest must surface as + # StoreCorruptError like every other one. + raise ValueError( + f"Node {node_id!r} typed artifact carries a numeric scope its " + f"consumer may not read: {error}" + ) from error + edges[node_id].add(producer_id) + + memo: dict[str, frozenset[str]] = {} + + def visit(node_id: str, trail: frozenset[str]) -> frozenset[str]: + if node_id in trail: + raise ValueError("Typed artifact ancestry contains a cycle.") + if node_id in memo: + return memo[node_id] + ancestors = set(edges[node_id]) + for parent in edges[node_id]: + ancestors.update(visit(parent, trail | {node_id})) + memo[node_id] = frozenset(ancestors) + return memo[node_id] + + for node_id, node in nodes.items(): + ancestors = visit(node_id, frozenset()) + if _capability_role(node) is not KernelRole.RELEASE: + continue + artifact_gates = { + parent + for parent in ancestors + if _capability_role(nodes[parent]) is KernelRole.GATE + } + # Only look at `gate_ancestry` when a byte edge actually reached a + # gate. Otherwise `RunManifest.tier` keeps sole ownership of that + # field's validation and its precise diagnostic. When a byte edge did + # reach a gate, the shape is checked here first, with the same + # diagnostic `tier` gives, so a malformed value from a foreign + # manifest never surfaces as a bare TypeError from `set()`. + if not artifact_gates: + continue + gate_ancestry = node.receipt.get("gate_ancestry", ()) + if not isinstance(gate_ancestry, tuple) or any( + not isinstance(gate_id, str) or not gate_id for gate_id in gate_ancestry + ): + raise ValueError( + f"release node {node_id!r} has invalid gate ancestry {gate_ancestry!r}" + ) + if not artifact_gates.issubset(set(gate_ancestry)): + raise ValueError("Release omitted a typed artifact gate ancestor.") diff --git a/packages/microcosm-graph/src/microcosm/graph/serialize.py b/packages/microcosm-graph/src/microcosm/graph/serialize.py index 33b5437f3..e513c3b7a 100644 --- a/packages/microcosm-graph/src/microcosm/graph/serialize.py +++ b/packages/microcosm-graph/src/microcosm/graph/serialize.py @@ -7,6 +7,9 @@ from .canonical import canonical_json from .decl import ( + ArtifactInput, + ArtifactOutput, + ArtifactType, Graph, Node, Owned, @@ -90,6 +93,36 @@ def _partition_from_payload(value: object, label: str) -> tuple[str, str] | None def _node_payload(node: Node) -> dict[str, object]: return { + # Elided when empty, so a graph declaring no artifact edges + # serializes exactly as it did before amendment 19. + **( + { + "artifact_inputs": [ + { + "name": item.name, + "producer": item.producer, + "artifact": item.artifact, + "type": _artifact_type_payload(item.type), + } + for item in node.artifact_inputs + ] + } + if node.artifact_inputs + else {} + ), + **( + { + "artifact_outputs": [ + { + "name": item.name, + "type": _artifact_type_payload(item.type), + } + for item in node.artifact_outputs + ] + } + if node.artifact_outputs + else {} + ), "id": node.id, "kernel": node.kernel, "inputs": [ @@ -161,6 +194,9 @@ def _node_from_payload(value: object, index: int) -> Node: "description", "citation", } + fields.update( + name for name in ("artifact_inputs", "artifact_outputs") if name in payload + ) if "entrants" in payload: fields.add("entrants") _exact_fields(payload, fields, label) @@ -174,6 +210,18 @@ def _node_from_payload(value: object, index: int) -> Node: population = _optional_string(payload["population"], f"{label}.population") base = _optional_string(payload["base"], f"{label}.base") return Node( + artifact_inputs=tuple( + _artifact_from_payload(value, input_=True) + for value in _array( + payload.get("artifact_inputs", []), f"{label}.artifact_inputs" + ) + ), + artifact_outputs=tuple( + _artifact_from_payload(value, input_=False) + for value in _array( + payload.get("artifact_outputs", []), f"{label}.artifact_outputs" + ) + ), id=_string(payload["id"], f"{label}.id"), kernel=_string(payload["kernel"], f"{label}.kernel"), inputs=tuple( @@ -302,3 +350,29 @@ def _exact_fields( def _reject_json_constant(value: str) -> object: raise ValueError(f"graph JSON contains non-finite constant {value}") + + +def _artifact_type_payload(type_: ArtifactType) -> dict[str, object]: + return {"name": type_.name, "schema_version": type_.schema_version} + + +def _artifact_from_payload( + value: object, *, input_: bool +) -> ArtifactInput | ArtifactOutput: + raw = _mapping(value, "artifact declaration") + fields = {"name", "type"} | ({"producer", "artifact"} if input_ else set()) + _exact_fields(raw, fields, "artifact declaration") + type_raw = _mapping(raw["type"], "artifact type") + _exact_fields(type_raw, {"name", "schema_version"}, "artifact type") + type_ = ArtifactType( + _string(type_raw["name"], "artifact type.name"), type_raw["schema_version"] + ) + name = _string(raw["name"], "artifact name") + if input_: + return ArtifactInput( + name, + _string(raw["producer"], "artifact producer"), + _string(raw["artifact"], "artifact output"), + type_, + ) + return ArtifactOutput(name, type_) diff --git a/packages/microcosm-graph/src/microcosm/graph/view.py b/packages/microcosm-graph/src/microcosm/graph/view.py index 63820048a..77f231269 100644 --- a/packages/microcosm-graph/src/microcosm/graph/view.py +++ b/packages/microcosm-graph/src/microcosm/graph/view.py @@ -87,6 +87,23 @@ def describe( f"Implementation hash: {implementation}", ] ) + if node.artifact_inputs: + lines.append( + "Artifact inputs: " + + "; ".join( + f"{item.name} <- {item.producer}.{item.artifact} " + f"({item.type.name}@{item.type.schema_version})" + for item in node.artifact_inputs + ) + ) + if node.artifact_outputs: + lines.append( + "Artifact outputs: " + + "; ".join( + f"{item.name} ({item.type.name}@{item.type.schema_version})" + for item in node.artifact_outputs + ) + ) if run_receipt is None: lines.append( 'Seed: int.from_bytes(sha256(b"seed\\0" + node_key)[:8], "little")' diff --git a/packages/microcosm-graph/tests/test_acceptance_b_ownership.py b/packages/microcosm-graph/tests/test_acceptance_b_ownership.py index 2ce41a7f9..e4f065284 100644 --- a/packages/microcosm-graph/tests/test_acceptance_b_ownership.py +++ b/packages/microcosm-graph/tests/test_acceptance_b_ownership.py @@ -116,6 +116,7 @@ def test_b2_executor_enforces_ownership(tmp_path: Path) -> None: "params", "rng", "sources", + "artifacts", # amendment 19: declared typed opaque artifact inputs "tolerances", # amendment 13: declared tolerances of the inputs' owners "numerics", # amendment 17: per-coordinate numeric class, bound, platform } diff --git a/packages/microcosm-graph/tests/test_graph_decl.py b/packages/microcosm-graph/tests/test_graph_decl.py index 9ee8426ac..aefff1e95 100644 --- a/packages/microcosm-graph/tests/test_graph_decl.py +++ b/packages/microcosm-graph/tests/test_graph_decl.py @@ -11,6 +11,9 @@ import pytest from microcosm.graph import ( + ArtifactInput, + ArtifactOutput, + ArtifactType, Graph, GraphError, Node, @@ -349,3 +352,128 @@ def test_every_declared_name_channel_refuses_dots() -> None: WeightTransition("house.hold", "design", "importance") with pytest.raises(GraphError, match="may not contain '.'"): Graph("toy", (), (), mass_partition=("person", "per.iod")) + + +ARTIFACT = ArtifactType("qrf.forest", 1) + + +def _producer( + node_id: str = "fit", *, outputs: tuple[ArtifactOutput, ...] = () +) -> Node: + return Node( + node_id, + "fit.train@1", + inputs=(Slice("person", ("age",)),), + artifact_outputs=outputs or (ArtifactOutput("forest", ARTIFACT),), + ) + + +def _consumer(binding: ArtifactInput, node_id: str = "draw") -> Node: + return Node( + node_id, + "fit.draw@1", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("person", "drawn", "float64"),), + artifact_inputs=(binding,), + ) + + +def test_an_artifact_edge_makes_its_producer_a_predecessor() -> None: + """Amendment 19: a typed byte edge orders the graph without owning a cell.""" + compiled = compile_graph( + Graph( + "toy", + (SRC,), + ( + _consumer(ArtifactInput("donor", "fit", "forest", ARTIFACT)), + _producer(), + CREATE, + ), + ) + ) + assert "fit" in compiled.predecessors["draw"] + assert compiled.order.index("fit") < compiled.order.index("draw") + # The producer owns no cell of the consumer's outputs. + assert compiled.owners[("survey", "person", "drawn")] == "draw" + + +def test_an_artifact_edge_is_refused_when_it_does_not_resolve() -> None: + """Amendment 19: producer, output name, and exact type all have to match.""" + with pytest.raises(GraphError, match="unknown artifact producer"): + compile_graph( + Graph( + "toy", + (SRC,), + ( + _consumer(ArtifactInput("donor", "absent", "forest", ARTIFACT)), + CREATE, + ), + ) + ) + with pytest.raises(GraphError, match="no declared artifact"): + compile_graph( + Graph( + "toy", + (SRC,), + ( + _consumer(ArtifactInput("donor", "fit", "other", ARTIFACT)), + _producer(), + CREATE, + ), + ) + ) + with pytest.raises(GraphError, match="type does not match its producer"): + compile_graph( + Graph( + "toy", + (SRC,), + ( + _consumer( + ArtifactInput( + "donor", "fit", "forest", ArtifactType("qrf.forest", 2) + ) + ), + _producer(), + CREATE, + ), + ) + ) + with pytest.raises(GraphError, match="depends on itself through an artifact"): + compile_graph( + Graph( + "toy", + (SRC,), + ( + Node( + "fit", + "fit.train@1", + inputs=(Slice("person", ("age",)),), + artifact_outputs=(ArtifactOutput("forest", ARTIFACT),), + artifact_inputs=( + ArtifactInput("self", "fit", "forest", ARTIFACT), + ), + ), + CREATE, + ), + ) + ) + + +def test_an_artifact_cycle_is_refused_like_any_other_cycle() -> None: + """Amendment 19: byte edges join the same depth computation as cell edges.""" + first = Node( + "first", + "fit.train@1", + inputs=(Slice("person", ("age",)),), + artifact_outputs=(ArtifactOutput("forest", ARTIFACT),), + artifact_inputs=(ArtifactInput("other", "second", "forest", ARTIFACT),), + ) + second = Node( + "second", + "fit.train@1", + inputs=(Slice("person", ("age",)),), + artifact_outputs=(ArtifactOutput("forest", ARTIFACT),), + artifact_inputs=(ArtifactInput("other", "first", "forest", ARTIFACT),), + ) + with pytest.raises(GraphError, match="[Cc]ycle"): + compile_graph(Graph("toy", (SRC,), (first, second, CREATE))) diff --git a/packages/microcosm-graph/tests/test_graph_executor.py b/packages/microcosm-graph/tests/test_graph_executor.py index be379eeb7..e6c12a8f1 100644 --- a/packages/microcosm-graph/tests/test_graph_executor.py +++ b/packages/microcosm-graph/tests/test_graph_executor.py @@ -17,6 +17,9 @@ import microcosm.graph.executor as graph_executor from microcosm.frame import EntitySchema, Frame, WeightKind, Weights from microcosm.graph.decl import ( + ArtifactInput, + ArtifactOutput, + ArtifactType, Graph, GraphError, Node, @@ -28,8 +31,10 @@ WeightTransition, compile_graph, ) +from microcosm.graph.errors import NodeRejectedError from microcosm.graph.executor import NodeRejected, run_graph from microcosm.graph.kernel import ( + ArtifactValue, Capabilities, Determinism, KernelContext, @@ -40,7 +45,7 @@ NumericScope, Tolerance, ) -from microcosm.graph.keys import platform_fingerprint +from microcosm.graph.keys import opaque_artifact_key, platform_fingerprint from microcosm.graph.manifest import Decision, RunManifest from microcosm.graph.store import ( ContentStore, @@ -3364,3 +3369,501 @@ def must_not_run(context: KernelContext) -> KernelResult: registry, ) assert mask_kernel.calls == size_kernel.calls == 0 + + +# --- Amendment 19: typed opaque artifacts ----------------------------------- + +FOREST = ArtifactType("qrf.forest", 1) + + +def _artifact_graph( + *, + declare_output: bool = True, + consumer_type: ArtifactType = FOREST, +) -> Graph: + fit = Node( + "fit", + "fit@1", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("person", "fitted", "float64"),), + params={"source": "age", "target": "fitted", "scale": 1.0}, + population="survey", + artifact_outputs=(ArtifactOutput("forest", FOREST),) if declare_output else (), + ) + draw = Node( + "draw", + "consume@1", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("person", "drawn", "float64"),), + population="survey", + artifact_inputs=(ArtifactInput("donor", "fit", "forest", consumer_type),), + ) + return Graph("toy", (SOURCE,), (CREATE, fit, draw)) + + +def _artifact_registry( + *, + seen: list[Mapping[str, object]] | None = None, + payload: bytes = b"forest-bytes", + emit: bool = True, + producer: Capabilities | None = None, + consumer: Capabilities | None = None, +) -> KernelRegistry: + def fit(context: KernelContext) -> KernelResult: + table = context.tables["person"] + return KernelResult( + columns={ + ("person", "fitted"): pd.Series( + table["age"].to_numpy(dtype=np.float64), + index=pd.Index(table["person_id"], name="person_id"), + dtype="float64", + ) + }, + artifacts=({"forest": payload} if emit else {}) | {"notes": b"diagnostic"}, + receipt={"trees": 3}, + ) + + def consume(context: KernelContext) -> KernelResult: + if seen is not None: + seen.append(dict(context.artifacts)) + donor = context.artifacts["donor"] + table = context.tables["person"] + return KernelResult( + columns={ + ("person", "drawn"): pd.Series( + np.full(len(table), float(len(donor.payload))), + index=pd.Index(table["person_id"], name="person_id"), + dtype="float64", + ) + }, + receipt={"payload_bytes": len(donor.payload)}, + ) + + registry = _registry() + registry.register( + _Kernel("fit@1", producer or Capabilities(Determinism.DETERMINISTIC), fit) + ) + registry.register( + _Kernel( + "consume@1", consumer or Capabilities(Determinism.DETERMINISTIC), consume + ) + ) + return registry + + +def test_a_declared_artifact_reaches_its_consumer_verified(tmp_path: Path) -> None: + """Amendment 19: the executor hands over the producer's exact bytes.""" + seen: list[Mapping[str, object]] = [] + store = ContentStore(tmp_path / "store") + registry = _artifact_registry(seen=seen) + manifest = _run(_artifact_graph(), _source_path(tmp_path / "src"), store, registry) + assert len(seen) == 1 + donor = seen[0]["donor"] + assert set(seen[0]) == {"donor"} + assert donor.payload == b"forest-bytes" + assert donor.type == FOREST + producer_key = manifest.nodes["fit"].key + assert donor.producer_key == producer_key + assert donor.key == opaque_artifact_key(producer_key, "forest") + assert donor.numerics == NumericScope(numeric=Numeric.BITWISE) + assert manifest.nodes["fit"].opaque_artifacts["forest"] == donor.key + # Every person row carries the payload length the consumer measured. + drawn = manifest.populations["survey"].person["drawn"] + assert set(drawn.to_numpy()) == {float(len(b"forest-bytes"))} + + +def test_a_kernel_that_omits_a_declared_artifact_is_rejected(tmp_path: Path) -> None: + """Amendment 19: a declared output is required of the kernel.""" + store = ContentStore(tmp_path / "store") + registry = _artifact_registry(emit=False) + with pytest.raises(NodeRejected, match="missing declared artifact 'forest'"): + _run(_artifact_graph(), _source_path(tmp_path / "src"), store, registry) + + +def test_undeclared_opaque_bytes_stay_legal_and_unaddressable(tmp_path: Path) -> None: + """Amendment 19: only declared outputs become artifact edges.""" + store = ContentStore(tmp_path / "store") + manifest = _run( + _artifact_graph(), _source_path(tmp_path / "src"), store, _artifact_registry() + ) + assert set(manifest.nodes["fit"].opaque_artifacts) == {"forest", "notes"} + assert set(manifest.nodes["fit"].typed_artifacts["outputs"]) == {"forest"} + graph = _artifact_graph() + bad = Graph( + graph.country, + graph.sources, + tuple( + node + if node.id != "draw" + else replace( + node, + artifact_inputs=(ArtifactInput("donor", "fit", "notes", FOREST),), + ) + for node in graph.nodes + ), + ) + with pytest.raises(GraphError, match="no declared artifact 'notes'"): + compile_graph(bad) + + +def test_an_artifact_edge_is_memoized_like_every_other_input(tmp_path: Path) -> None: + """Amendment 19: a second run hits the store and executes no kernel.""" + store = ContentStore(tmp_path / "store") + source = _source_path(tmp_path / "src") + first = _artifact_registry() + _run(_artifact_graph(), source, store, first) + second = _artifact_registry() + manifest = _run(_artifact_graph(), source, store, second) + assert all(receipt.hit for receipt in manifest.nodes.values()) + assert _calls(second)["fit@1"] == 0 + assert _calls(second)["consume@1"] == 0 + + +def test_a_cached_typed_contract_that_disagrees_with_the_graph_is_refused( + tmp_path: Path, +) -> None: + """Amendment 19: the cached record pins the typed contract it was run under.""" + store = ContentStore(tmp_path / "store") + source = _source_path(tmp_path / "src") + manifest = _run(_artifact_graph(), source, store, _artifact_registry()) + # Rewriting a cached consumer record's contract is corruption, not a miss. + key = manifest.nodes["draw"].key + raw = store.load_json(graph_executor._cache_record_key(key)) + raw["typed_artifacts"]["inputs"]["donor"]["type"]["schema_version"] = 99 + store.put_json( + graph_executor._cache_record_key(key), raw, node_key=key, verify_existing=False + ) + with pytest.raises(StoreCorrupt, match="typed artifact contracts disagree"): + _run(_artifact_graph(), source, store, _artifact_registry()) + + +def test_the_manifest_records_typed_provenance_and_round_trips(tmp_path: Path) -> None: + """Amendment 19: typed edges are portable provenance at manifest schema 3.""" + store = ContentStore(tmp_path / "store") + manifest = _run( + _artifact_graph(), _source_path(tmp_path / "src"), store, _artifact_registry() + ) + binding = manifest.nodes["draw"].typed_artifacts["inputs"]["donor"] + assert binding["producer"] == "fit" and binding["artifact"] == "forest" + assert binding["producer_key"] == manifest.nodes["fit"].key + text = manifest.to_json() + assert '"schema_version":3' in text + restored = RunManifest.from_json(text) + assert restored.key == manifest.key + assert ( + restored.nodes["draw"].typed_artifacts == manifest.nodes["draw"].typed_artifacts + ) + + +def test_a_typed_edge_refuses_to_launder_its_producer_numeric_scope( + tmp_path: Path, +) -> None: + """Amendment 19 x 16/17: bytes carry their producer's numeric contract.""" + store = ContentStore(tmp_path / "store") + source = _source_path(tmp_path / "src") + registry = _artifact_registry( + producer=Capabilities(Determinism.SEEDED, numeric=Numeric.PLATFORM_BITWISE) + ) + with pytest.raises(NodeRejectedError, match="platform_bitwise artifact requires"): + _run(_artifact_graph(), source, store, registry) + bounded = _artifact_registry( + producer=Capabilities( + Determinism.SEEDED, + numeric=Numeric.TOLERANCE_BOUND, + tolerance=Tolerance(rtol=1e-6), + ) + ) + with pytest.raises(NodeRejectedError, match="tolerance_bound artifact requires"): + _run(_artifact_graph(), source, store, bounded) + # A consumer that declares the producer's class reads the bytes. + matched = _artifact_registry( + producer=Capabilities(Determinism.SEEDED, numeric=Numeric.PLATFORM_BITWISE), + consumer=Capabilities(Determinism.SEEDED, numeric=Numeric.PLATFORM_BITWISE), + ) + manifest = _run(_artifact_graph(), source, store, matched) + scope = manifest.nodes["draw"].typed_artifacts["inputs"]["donor"]["numerics"] + assert scope["numeric"] == "platform_bitwise" + assert scope["platform"] == platform_fingerprint() + + +def test_an_artifact_payload_enters_the_input_context_digest(tmp_path: Path) -> None: + """Amendment 19: artifact bytes are input state, so mutation is detectable.""" + node = Node("draw", "consume@1") + scope = NumericScope() + base = dict( + node=node, + tables={}, + weights={}, + strata=pd.Series(dtype="int64"), + params={}, + rng=np.random.default_rng(0), + ) + first = KernelContext( + **base, + artifacts={"donor": ArtifactValue(b"one", FOREST, "a" * 64, "b" * 64, scope)}, + ) + second = KernelContext( + **base, + artifacts={"donor": ArtifactValue(b"two", FOREST, "a" * 64, "b" * 64, scope)}, + ) + bare = KernelContext(**base) + digest = graph_executor._context_digest + assert digest(first) != digest(second) + assert digest(first) != digest(bare) + assert digest(first) == digest( + KernelContext( + **base, + artifacts={ + "donor": ArtifactValue(b"one", FOREST, "a" * 64, "b" * 64, scope) + }, + ) + ) + + +def test_a_gate_kernel_may_not_declare_a_typed_artifact_output(tmp_path: Path) -> None: + """Amendment 19 holds amendment 7: a gate exception stays a verdict. + + A gate whose kernel raises produces a synthesized ``fail`` result with no + artifacts, so a declared typed output would turn that verdict into an + aborted run. Amendment 19 carries no regime for an output a node was + unable to produce, so the declaration is refused outright. + """ + gate = Node( + "gate", + "gate@1", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("household", "gate_verdict", "string"),), + population="survey", + artifact_outputs=( + ArtifactOutput("evidence", ArtifactType("gate.evidence", 1)), + ), + ) + graph = Graph("toy", (SOURCE,), (CREATE, gate)) + registry = _registry() + registry.register( + _Kernel( + "gate@1", + Capabilities(Determinism.DETERMINISTIC, role=KernelRole.GATE), + lambda context: KernelResult(receipt={"outcome": "pass"}), + ) + ) + store = ContentStore(tmp_path / "store") + with pytest.raises(NodeRejected, match="gate kernel may not declare"): + _run(graph, _source_path(tmp_path / "src"), store, registry) + + +def test_a_gate_reached_only_through_bytes_still_derives_the_tier( + tmp_path: Path, +) -> None: + """Amendment 19 cannot route around F2: a byte edge is a real ancestor. + + ``gate`` fails and owns a column only ``fit`` reads; ``fit``'s bytes are + the only path from that subgraph to ``release``. The release's gate + ancestry must still name the gate, so its tier is evidence. + """ + gate = Node( + "gate", + "gate@1", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("household", "gate_verdict", "string"),), + params={"outcome": "fail"}, + population="survey", + ) + fit = Node( + "fit", + "fit@1", + inputs=(Slice("household", ("gate_verdict",)),), + outputs=(Owned("person", "fitted", "float64"),), + params={"source": "age", "target": "fitted", "scale": 1.0}, + population="survey", + artifact_outputs=(ArtifactOutput("forest", FOREST),), + ) + release = Node( + "release", + "release@1", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("household", "tier", "string"),), + params={"answer": "evidence", "requires_decisions": ()}, + population="survey", + artifact_inputs=(ArtifactInput("donor", "fit", "forest", FOREST),), + ) + graph = Graph("toy", (SOURCE,), (CREATE, gate, fit, release)) + compiled = compile_graph(graph) + assert "fit" in compiled.predecessors["release"] + + registry = _release_registry() + registry.register( + _Kernel( + "fit@1", + Capabilities(Determinism.DETERMINISTIC), + lambda context: KernelResult( + columns={ + ("person", "fitted"): pd.Series( + np.zeros(len(context.tables["person"])), + index=pd.Index( + context.tables["person"]["person_id"], name="person_id" + ), + dtype="float64", + ) + }, + artifacts={"forest": b"forest-bytes"}, + ), + ) + ) + store = ContentStore(tmp_path / "store") + manifest = _run(graph, _source_path(tmp_path / "src"), store, registry) + assert manifest.nodes["release"].receipt["gate_ancestry"] == ("gate",) + assert manifest.tier == "evidence" + restored = RunManifest.from_json(manifest.to_json()) + assert restored.tier == "evidence" + + +def test_an_artifact_edge_crosses_population_versions_and_resume_policies( + tmp_path: Path, +) -> None: + """Amendment 19: a byte edge is not confined to one population version. + + ``fit`` lives in the ``survey`` version; ``draw`` lives in the version a + FILTER opens. The bytes cross the boundary, the producer is still a + predecessor, and all three resume policies agree. + """ + + def keep_all(context: KernelContext) -> KernelResult: + person = context.tables["person"] + return KernelResult( + keep=pd.Series(True, index=person["person_id"], dtype="bool") + ) + + fit = Node( + "fit", + "fit@1", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("person", "fitted", "float64"),), + params={"source": "age", "target": "fitted", "scale": 1.0}, + population="survey", + artifact_outputs=(ArtifactOutput("forest", FOREST),), + ) + boundary = Node( + "boundary", + "identity.filter@1", + inputs=(Slice("person", ("selected",)),), + structural=StructuralDelta.FILTER, + base="survey", + ) + draw = Node( + "draw", + "consume@1", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("person", "drawn", "float64"),), + population="boundary", + artifact_inputs=(ArtifactInput("donor", "fit", "forest", FOREST),), + ) + graph = Graph("toy", (SOURCE,), (CREATE, fit, boundary, draw)) + compiled = compile_graph(graph) + assert "fit" in compiled.predecessors["draw"] + assert compiled.versions["draw"] == "boundary" + + source = _source_path(tmp_path / "src") + store = ContentStore(tmp_path / "store") + + def registry() -> KernelRegistry: + built = _artifact_registry() + built.register( + _Kernel( + "identity.filter@1", + Capabilities( + Determinism.DETERMINISTIC, structural=StructuralDelta.FILTER + ), + keep_all, + ) + ) + return built + + cold = _run(graph, source, store, registry()) + assert not any(receipt.hit for receipt in cold.nodes.values()) + warm = _run(graph, source, store, registry(), resume="require") + assert all(receipt.hit for receipt in warm.nodes.values()) + assert warm.nodes["draw"].key == cold.nodes["draw"].key + + forbidden = _run(graph, source, store, registry(), resume="forbid") + assert not any(receipt.hit for receipt in forbidden.nodes.values()) + assert forbidden.nodes["draw"].key == cold.nodes["draw"].key + assert forbidden.nodes["draw"].typed_artifacts == cold.nodes["draw"].typed_artifacts + + +def test_a_cached_record_missing_a_declared_artifact_is_a_miss(tmp_path: Path) -> None: + """Amendment 19: the miss decision lives inside the recompute fallback. + + Dropping the producer's stored artifact entry makes its cached record a + miss, so ``auto`` re-executes the kernel and rewrites the bytes, and + ``require`` reports the miss rather than a corrupt store. + """ + store = ContentStore(tmp_path / "store") + source = _source_path(tmp_path / "src") + first = _run(_artifact_graph(), source, store, _artifact_registry()) + key = first.nodes["fit"].key + record_key = graph_executor._cache_record_key(key) + record = store.load_json(record_key) + record["opaque"] = [ + entry for entry in record["opaque"] if entry["name"] != "forest" + ] + store.put_json(record_key, record, node_key=key, verify_existing=False) + + with pytest.raises(StoreMiss, match="cache misses before execution: 'fit'"): + _run(_artifact_graph(), source, store, _artifact_registry(), resume="require") + + recovered = _artifact_registry() + second = _run(_artifact_graph(), source, store, recovered) + assert not second.nodes["fit"].hit + assert _calls(recovered)["fit@1"] == 1 + assert second.nodes["fit"].key == key + assert second.nodes["fit"].opaque_artifacts["forest"] == opaque_artifact_key( + key, "forest" + ) + + +def test_a_cache_hit_authenticates_its_artifact_edges_without_reading_them( + tmp_path: Path, +) -> None: + """Amendment 19: identity is checked from receipts; bytes are read to run. + + A node that hits its cached record never runs a kernel, so its declared + inputs' payloads are not read — but the producer receipt and the + descriptor are still authenticated. + """ + + class _CountingStore(ContentStore): + loads: list[str] = [] + + def load_bytes(self, key: str) -> bytes: + type(self).loads.append(key) + return super().load_bytes(key) + + source = _source_path(tmp_path / "src") + _CountingStore.loads = [] + store = _CountingStore(tmp_path / "store") + cold = _run(_artifact_graph(), source, store, _artifact_registry()) + artifact = cold.nodes["fit"].opaque_artifacts["forest"] + assert artifact in _CountingStore.loads # the consumer ran, so it read them + + _CountingStore.loads = [] + warm = _run(_artifact_graph(), source, store, _artifact_registry()) + assert all(receipt.hit for receipt in warm.nodes.values()) + # The producer's own restore still reads its stored artifacts; no consumer + # read happens on top of that. + assert _CountingStore.loads.count(artifact) == 1 + + # Tampering with the producer's recorded identity is still caught on a + # hit, without any payload being read for the consumer. The guard that + # fires is the record-shape contract check on the producer's own restore, + # which is why the consumer's later receipt comparison never has to. + _CountingStore.loads = [] + graph = _artifact_graph() + record_key = graph_executor._cache_record_key(cold.nodes["fit"].key) + record = store.load_json(record_key) + record["typed_artifacts"]["outputs"]["forest"]["key"] = "0" * 64 + store.put_json( + record_key, record, node_key=cold.nodes["fit"].key, verify_existing=False + ) + with pytest.raises(StoreCorrupt, match="typed artifact contracts disagree"): + _run(graph, source, store, _artifact_registry()) diff --git a/packages/microcosm-graph/tests/test_graph_kernel_contract.py b/packages/microcosm-graph/tests/test_graph_kernel_contract.py index 057bec79f..61a8fd2e2 100644 --- a/packages/microcosm-graph/tests/test_graph_kernel_contract.py +++ b/packages/microcosm-graph/tests/test_graph_kernel_contract.py @@ -1,8 +1,10 @@ -"""Kernel-protocol contracts of the frozen interface (amendment 13). +"""Kernel-protocol contracts of the frozen interface (amendments 13, 17, 19). A kernel that claims bounded numeric movement declares the bound; a bitwise kernel declares none; the context hands readers their inputs' declared -tolerances; and the two new declaration fields round-trip through JSON. +tolerances and numeric scopes; declared typed artifacts reach a consumer as +verified immutable bytes; and the new declaration fields round-trip through +JSON. """ from __future__ import annotations @@ -14,9 +16,14 @@ import pytest from microcosm.graph import ( + ArtifactInput, + ArtifactOutput, + ArtifactType, + ArtifactValue, Capabilities, Determinism, Graph, + GraphError, KernelBase, KernelContext, KernelRegistry, @@ -24,6 +31,7 @@ Numeric, NumericScope, Owned, + Slice, SourceRef, StructuralDelta, Tolerance, @@ -209,3 +217,199 @@ def test_context_numerics_default_empty_and_carry_scopes() -> None: ) assert context.numerics[("person", "income")] is scope assert context.tolerances[("person", "income")] is None + + +def test_artifact_type_names_a_versioned_payload_contract() -> None: + """Amendment 19: a nominal type is a non-empty name and a positive version.""" + assert ArtifactType("qrf.forest", 1).schema_version == 1 + with pytest.raises(GraphError, match="ArtifactType.name"): + ArtifactType("", 1) + with pytest.raises(GraphError, match="positive integer"): + ArtifactType("qrf.forest", 0) + with pytest.raises(GraphError, match="positive integer"): + ArtifactType("qrf.forest", True) # type: ignore[arg-type] + with pytest.raises(GraphError, match="positive integer"): + ArtifactType("qrf.forest", 1.0) # type: ignore[arg-type] + + +def test_artifact_declarations_require_a_real_artifact_type() -> None: + """Amendment 19: a look-alike mapping is not an ArtifactType.""" + type_ = ArtifactType("qrf.forest", 2) + assert ArtifactOutput("forest", type_).type is type_ + binding = ArtifactInput("donor", "fit", "forest", type_) + assert (binding.producer, binding.artifact) == ("fit", "forest") + with pytest.raises(GraphError, match="ArtifactOutput.name"): + ArtifactOutput("", type_) + with pytest.raises(GraphError, match="ArtifactOutput.type"): + ArtifactOutput("forest", {"name": "qrf.forest", "schema_version": 2}) # type: ignore[arg-type] + for empty in ("name", "producer", "artifact"): + values = { + "name": "donor", + "producer": "fit", + "artifact": "forest", + **{empty: ""}, + } + with pytest.raises(GraphError, match=f"ArtifactInput.{empty}"): + ArtifactInput(type=type_, **values) + with pytest.raises(GraphError, match="ArtifactInput.type"): + ArtifactInput("donor", "fit", "forest", "qrf.forest@2") # type: ignore[arg-type] + + +def test_node_artifact_declarations_are_tuples_with_unique_aliases() -> None: + """Amendment 19: the two new declaration fields validate at construction.""" + type_ = ArtifactType("qrf.forest", 1) + node = Node( + "draw", + "fit.draw@1", + population="survey", + artifact_inputs=(ArtifactInput("donor", "fit", "forest", type_),), + artifact_outputs=(ArtifactOutput("diagnostics", ArtifactType("table", 1)),), + ) + assert node.artifact_inputs[0].producer == "fit" + assert node.artifact_outputs[0].name == "diagnostics" + with pytest.raises(GraphError, match="artifact_inputs must be a tuple"): + Node( + "draw", "fit.draw@1", artifact_inputs=[ArtifactInput("d", "f", "o", type_)] + ) # type: ignore[arg-type] + with pytest.raises(GraphError, match="artifact_outputs must be a tuple"): + Node( + "draw", + "fit.draw@1", + artifact_outputs=(ArtifactInput("d", "f", "o", type_),), + ) # type: ignore[arg-type] + with pytest.raises(GraphError, match="duplicate names in artifact_inputs"): + Node( + "draw", + "fit.draw@1", + artifact_inputs=( + ArtifactInput("donor", "fit", "forest", type_), + ArtifactInput("donor", "other", "forest", type_), + ), + ) + with pytest.raises(GraphError, match="duplicate names in artifact_outputs"): + Node( + "fit", + "fit.train@1", + artifact_outputs=( + ArtifactOutput("forest", type_), + ArtifactOutput("forest", ArtifactType("other", 1)), + ), + ) + + +def test_a_node_declaring_no_artifacts_projects_exactly_as_before() -> None: + """Amendment 19: empty declarations leave the canonical projection alone.""" + plain = Node("draw", "fit.draw@1", population="survey") + projection = plain.normative() + assert "artifact_inputs" not in projection + assert "artifact_outputs" not in projection + declared = dataclasses.replace( + plain, + artifact_outputs=(ArtifactOutput("forest", ArtifactType("qrf.forest", 1)),), + ) + assert declared.normative()["artifact_outputs"] == declared.artifact_outputs + assert set(declared.normative()) - set(projection) == {"artifact_outputs"} + + +def test_artifact_value_validates_its_payload_type_and_identities() -> None: + """Amendment 19: verified immutable bytes with typed producer provenance.""" + type_ = ArtifactType("qrf.forest", 1) + scope = NumericScope() + key = "a" * 64 + value = ArtifactValue(b"payload", type_, key, "b" * 64, scope) + assert value.payload == b"payload" and value.numerics is scope + with pytest.raises(TypeError, match="immutable bytes"): + ArtifactValue(bytearray(b"payload"), type_, key, "b" * 64, scope) # type: ignore[arg-type] + with pytest.raises(TypeError, match="ArtifactType and NumericScope"): + ArtifactValue(b"payload", type_, key, "b" * 64, None) # type: ignore[arg-type] + with pytest.raises(TypeError, match="ArtifactType and NumericScope"): + ArtifactValue(b"payload", "qrf.forest@1", key, "b" * 64, scope) # type: ignore[arg-type] + with pytest.raises(ValueError, match="ArtifactValue.key"): + ArtifactValue(b"payload", type_, "a" * 63, "b" * 64, scope) + with pytest.raises(ValueError, match="ArtifactValue.key"): + ArtifactValue(b"payload", type_, "A" * 64, "b" * 64, scope) + with pytest.raises(ValueError, match="ArtifactValue.producer_key"): + ArtifactValue(b"payload", type_, key, "not-a-key", scope) + + +def test_context_artifacts_default_empty_and_are_immutable() -> None: + """Amendment 19: ``artifacts`` rides before the amendment-13/17 pair.""" + fields = [f.name for f in dataclasses.fields(KernelContext)] + assert fields[-2:] == ["tolerances", "numerics"] + assert fields[fields.index("artifacts") + 1] == "tolerances" + node = Node("draw", "fit.draw@1") + bare = KernelContext( + node=node, + tables={}, + weights={}, + strata=pd.Series(dtype="int64"), + params={}, + rng=np.random.default_rng(0), + ) + assert dict(bare.artifacts) == {} + value = ArtifactValue( + b"payload", ArtifactType("qrf.forest", 1), "a" * 64, "b" * 64, NumericScope() + ) + carried = KernelContext( + node=node, + tables={}, + weights={}, + strata=pd.Series(dtype="int64"), + params={}, + rng=np.random.default_rng(0), + artifacts={"donor": value}, + ) + assert carried.artifacts["donor"] is value + with pytest.raises(TypeError): + carried.artifacts["other"] = value # type: ignore[index] + for bad in ({"": value}, {"donor": b"payload"}, {1: value}): + with pytest.raises(TypeError, match="non-empty aliases to ArtifactValue"): + KernelContext( + node=node, + tables={}, + weights={}, + strata=pd.Series(dtype="int64"), + params={}, + rng=np.random.default_rng(0), + artifacts=bad, # type: ignore[arg-type] + ) + + +def test_artifact_declarations_round_trip_through_canonical_json() -> None: + """Amendment 19: declared artifact edges survive serialization exactly.""" + source = SourceRef("survey", "frame-h5") + create = Node( + "survey", + "source.frame@1", + sources=("survey",), + structural=StructuralDelta.CREATE, + outputs=(Owned("person", "age", "int64"),), + ) + type_ = ArtifactType("qrf.forest", 3) + fit = Node( + "fit", + "fit.train@1", + population="survey", + inputs=(Slice("person", ("age",)),), + artifact_outputs=(ArtifactOutput("forest", type_),), + ) + draw = Node( + "draw", + "fit.draw@1", + population="survey", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("person", "income", "float64"),), + artifact_inputs=(ArtifactInput("donor", "fit", "forest", type_),), + ) + graph = Graph("toy", (source,), (create, fit, draw)) + text = graph_to_json(graph) + assert '"artifact_outputs":' in text and '"artifact_inputs":' in text + assert '"schema_version":3' in text + assert graph_from_json(text) == graph + # A graph declaring no artifacts serializes exactly as it did before + # amendment 19, so every pinned graph JSON still matches. + plain = Graph("toy", (source,), (create,)) + plain_text = graph_to_json(plain) + assert "artifact_inputs" not in plain_text + assert "artifact_outputs" not in plain_text + assert graph_from_json(plain_text) == plain diff --git a/packages/microcosm-graph/tests/test_graph_keys.py b/packages/microcosm-graph/tests/test_graph_keys.py index 3b0fc89f5..46527039e 100644 --- a/packages/microcosm-graph/tests/test_graph_keys.py +++ b/packages/microcosm-graph/tests/test_graph_keys.py @@ -10,6 +10,9 @@ from microcosm.graph.canonical import canonical_json, sha256_domain from microcosm.graph.decl import ( + ArtifactInput, + ArtifactOutput, + ArtifactType, CompiledGraph, Graph, Node, @@ -31,6 +34,7 @@ artifact_key, frame_key, node_key, + opaque_artifact_key, seed, source_content_key, weights_key, @@ -92,6 +96,7 @@ def _all_keys( implementation_hashes = { "source.frame@1": "a" * 64, "toy.model@1": "b" * 64, + "toy.fit@1": "1" * 64, **(hashes or {}), } keys: dict[str, str] = {} @@ -355,3 +360,108 @@ def test_platform_bitwise_keys_carry_the_platform( ) == node_key( compiled, "a", {"survey": "s" * 64}, "impl", {}, kernel_capabilities=plain ) + + +_FOREST = ArtifactType("qrf.forest", 1) + + +def _artifact_graph( + *, + declare: bool = True, + type_: ArtifactType = _FOREST, + alias: str = "donor", + output: str = "forest", +) -> Graph: + producer = Node( + "fit", + "toy.fit@1", + inputs=(Slice("person", ("age",)),), + artifact_outputs=(ArtifactOutput(output, type_),) if declare else (), + ) + consumer = Node( + "draw", + "toy.model@1", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("person", "drawn", "float64"),), + artifact_inputs=( + (ArtifactInput(alias, "fit", output, type_),) if declare else () + ), + ) + return Graph("toy", (SOURCE,), (CREATE, producer, consumer)) + + +def test_opaque_artifact_key_is_the_node_key_and_the_output_name() -> None: + """Amendment 19: a typed output keeps the pre-existing opaque identity.""" + key = "c" * 64 + expected = sha256_domain("node-artifact", canonical_json((key, "forest"))) + assert opaque_artifact_key(key, "forest") == expected + assert opaque_artifact_key(key, "forest") != opaque_artifact_key(key, "other") + assert opaque_artifact_key(key, "forest") != opaque_artifact_key("d" * 64, "forest") + + +def test_a_declared_artifact_edge_enters_both_ends_of_the_key() -> None: + """Amendment 19: the output declaration is normative and the input adds a term.""" + _, declared = _all_keys(_artifact_graph()) + _, undeclared = _all_keys(_artifact_graph(declare=False)) + assert declared["survey"] == undeclared["survey"] + assert declared["fit"] != undeclared["fit"] + assert declared["draw"] != undeclared["draw"] + _, again = _all_keys(_artifact_graph()) + assert again == declared + # And so the output's own store identity moves with its producer: typing + # existing bytes does not preserve the identity they were filed under. + assert opaque_artifact_key(declared["fit"], "forest") != opaque_artifact_key( + undeclared["fit"], "forest" + ) + + +def test_a_node_declaring_no_artifacts_keeps_its_pre_amendment_projection() -> None: + """Amendment 19: empty declarations are elided, so they add no key term.""" + compiled = compile_graph(_artifact_graph(declare=False)) + for node_id in compiled.order: + projection = compiled.graph.node(node_id).normative() + assert "artifact_inputs" not in projection + assert "artifact_outputs" not in projection + declared = compile_graph(_artifact_graph()).graph.node("draw").normative() + assert set(declared) - set(compiled.graph.node("draw").normative()) == { + "artifact_inputs" + } + + +def test_every_part_of_an_artifact_declaration_is_normative() -> None: + """Amendment 19: no field of a declared edge is inert.""" + _, base = _all_keys(_artifact_graph()) + _, retyped = _all_keys(_artifact_graph(type_=ArtifactType("qrf.forest", 2))) + _, renamed_type = _all_keys(_artifact_graph(type_=ArtifactType("other", 1))) + _, realiased = _all_keys(_artifact_graph(alias="teacher")) + _, renamed_output = _all_keys(_artifact_graph(output="trees")) + # A type or output rename moves both ends; the consumer-local alias moves + # only the consumer, because the producer never sees it. + for moved in (retyped, renamed_type, renamed_output): + assert moved["fit"] != base["fit"] + assert moved["draw"] != base["draw"] + assert realiased["fit"] == base["fit"] + assert realiased["draw"] != base["draw"] + assert base["survey"] == realiased["survey"] == retyped["survey"] + + +def test_a_byte_edge_carries_descendant_exact_invalidation() -> None: + """Amendment 19 keeps A3 over bytes: the producer's key is in the consumer's. + + The producer has its own kernel ref, so re-hashing only its + implementation isolates the byte edge: the consumer reads no cell of the + producer, and its key moves anyway. + """ + graph = _artifact_graph() + fit_hashes = {"toy.fit@1": "1" * 64} + _, base = _all_keys(graph, hashes=fit_hashes) + _, moved = _all_keys(graph, hashes={"toy.fit@1": "e" * 64}) + assert moved["survey"] == base["survey"] + assert moved["fit"] != base["fit"] + assert moved["draw"] != base["draw"] + # Without the declaration the same producer edit leaves the consumer alone. + plain = _artifact_graph(declare=False) + _, plain_base = _all_keys(plain, hashes=fit_hashes) + _, plain_moved = _all_keys(plain, hashes={"toy.fit@1": "e" * 64}) + assert plain_moved["fit"] != plain_base["fit"] + assert plain_moved["draw"] == plain_base["draw"] diff --git a/packages/microcosm-graph/tests/test_graph_manifest.py b/packages/microcosm-graph/tests/test_graph_manifest.py index 587f9a0d4..170923c83 100644 --- a/packages/microcosm-graph/tests/test_graph_manifest.py +++ b/packages/microcosm-graph/tests/test_graph_manifest.py @@ -15,6 +15,7 @@ from microcosm.graph.canonical import canonical_json, sha256_domain from microcosm.graph.decl import StructuralDelta from microcosm.graph.kernel import Capabilities, Determinism, KernelRole, SeedSource +from microcosm.graph.keys import opaque_artifact_key from microcosm.graph.manifest import Decision, NodeReceipt, PopulationView, RunManifest from microcosm.graph.population import MassRecord @@ -695,3 +696,411 @@ def test_package_exports_runtime_implementations_and_failures() -> None: assert graph_api.StoreCorrupt is graph_api.StoreCorruptError assert graph_api.StoreUnavailable is graph_api.StoreUnavailableError assert graph_api.NodeRejected is graph_api.NodeRejectedError + + +def _typed_descriptor(producer: str, artifact: str, producer_key: str) -> dict: + return { + "producer": producer, + "artifact": artifact, + "producer_key": producer_key, + "key": opaque_artifact_key(producer_key, artifact), + "type": {"name": "qrf.forest", "schema_version": 1}, + "numerics": {"numeric": "bitwise", "tolerance": None, "platform": None}, + } + + +def _typed_pair() -> tuple[NodeReceipt, NodeReceipt]: + """A producer of one typed artifact and the consumer that declares it.""" + producer_key = "a" * 64 + descriptor = _typed_descriptor("fit", "forest", producer_key) + producer = NodeReceipt( + key=producer_key, + hit=False, + seed=1, + kernel_ref="fit@1", + kernel_impl_hash="b" * 64, + capabilities=_capabilities(), + receipt={"trees": 3}, + opaque_artifacts={"forest": descriptor["key"]}, + typed_artifacts={"inputs": {}, "outputs": {"forest": descriptor}}, + ) + consumer = NodeReceipt( + key="c" * 64, + hit=False, + seed=2, + kernel_ref="consume@1", + kernel_impl_hash="d" * 64, + capabilities=_capabilities(), + receipt={"read": True}, + typed_artifacts={"inputs": {"donor": descriptor}, "outputs": {}}, + ) + return producer, consumer + + +def test_a_manifest_authenticates_its_typed_artifact_edges() -> None: + """Amendment 19: a recorded edge must resolve to its producer's output.""" + producer, consumer = _typed_pair() + RunManifest( + country="toy", + nodes={"fit": producer, "draw": consumer}, + started_at="t0", + finished_at="t1", + host="h", + ) + with pytest.raises(ValueError, match="producer is missing or inconsistent"): + RunManifest( + country="toy", + nodes={"draw": consumer}, + started_at="t0", + finished_at="t1", + host="h", + ) + stripped = replace(producer, opaque_artifacts={}) + with pytest.raises(ValueError, match="output provenance mismatch"): + RunManifest( + country="toy", + nodes={"fit": stripped, "draw": consumer}, + started_at="t0", + finished_at="t1", + host="h", + ) + renamed = _typed_descriptor("fit", "other", producer.key) + mismatched = replace( + consumer, typed_artifacts={"inputs": {"donor": renamed}, "outputs": {}} + ) + with pytest.raises(ValueError, match="does not match its producer output"): + RunManifest( + country="toy", + nodes={"fit": producer, "draw": mismatched}, + started_at="t0", + finished_at="t1", + host="h", + ) + + +def test_a_release_may_not_omit_a_gate_reached_only_through_bytes() -> None: + """Amendment 19 cannot route around F2 by hiding a gate behind an edge.""" + gate_key = "a" * 64 + descriptor = _typed_descriptor("gate", "evidence", gate_key) + gate = NodeReceipt( + key=gate_key, + hit=False, + seed=1, + kernel_ref="gate@1", + kernel_impl_hash="b" * 64, + capabilities=_capabilities(KernelRole.GATE), + receipt={"outcome": "fail", "evidence": {"fixture": True}}, + opaque_artifacts={"evidence": descriptor["key"]}, + typed_artifacts={"inputs": {}, "outputs": {"evidence": descriptor}}, + ) + release = NodeReceipt( + key="c" * 64, + hit=False, + seed=2, + kernel_ref="release@1", + kernel_impl_hash="d" * 64, + capabilities=_capabilities(KernelRole.RELEASE), + receipt={ + "tier": "evidence", + "outcome": "fail", + "gate_ancestry": ["gate"], + "requires_decisions": [], + }, + typed_artifacts={"inputs": {"donor": descriptor}, "outputs": {}}, + ) + honest = RunManifest( + country="toy", + nodes={"gate": gate, "release": release}, + started_at="t0", + finished_at="t1", + host="h", + ) + assert honest.tier == "evidence" + hidden = replace( + release, + receipt={**dict(release.receipt), "gate_ancestry": [], "tier": "certified"}, + ) + with pytest.raises(ValueError, match="omitted a typed artifact gate ancestor"): + RunManifest( + country="toy", + nodes={"gate": gate, "release": hidden}, + started_at="t0", + finished_at="t1", + host="h", + ) + + +def test_a_laundered_artifact_scope_loads_as_store_corrupt(tmp_path: Path) -> None: + """Amendment 19: a corrupt manifest surfaces as StoreCorruptError, not a + node rejection, however its typed provenance was tampered with.""" + producer, consumer = _typed_pair() + manifest = RunManifest( + country="toy", + nodes={"fit": producer, "draw": consumer}, + started_at="t0", + finished_at="t1", + host="h", + ) + path = tmp_path / "manifest.json" + manifest.save(path) + document = json.loads(path.read_text()) + scope = { + "numeric": "platform_bitwise", + "tolerance": None, + "platform": "arm64/darwin/py3.14", + } + # A platform-bitwise producer whose bitwise consumer reads its bytes. The + # producer's own records stay self-consistent and both copies of every + # receipt agree, so only the cross-edge scope rule can catch it. + for nodes in (document["nodes"], document["content_addressed"]["nodes"]): + nodes["fit"]["capabilities"]["numeric"] = "platform_bitwise" + nodes["fit"]["typed_artifacts"]["outputs"]["forest"]["numerics"] = scope + nodes["draw"]["typed_artifacts"]["inputs"]["donor"]["numerics"] = scope + path.write_text(json.dumps(document)) + + store = graph_api.ContentStore(tmp_path / "store") + with pytest.raises(graph_api.StoreCorruptError, match="may not read"): + RunManifest.load(path, store) + + +def test_a_malformed_gate_ancestry_behind_a_byte_edge_keeps_the_tier_diagnostic() -> ( + None +): + """Amendment 19: a foreign manifest's bad ``gate_ancestry`` is named, not TypeError'd. + + When a byte edge reaches a gate, ``_validate_typed_ancestry`` reads the + release's ``gate_ancestry`` before ``tier`` does; a non-sequence there must + surface with ``tier``'s own diagnostic (node and offending value), never as + a bare ``TypeError`` from building a set of it. + """ + gate_key = "a" * 64 + descriptor = _typed_descriptor("gate", "evidence", gate_key) + gate = NodeReceipt( + key=gate_key, + hit=False, + seed=1, + kernel_ref="gate@1", + kernel_impl_hash="b" * 64, + capabilities=_capabilities(KernelRole.GATE), + receipt={"outcome": "fail", "evidence": {"fixture": True}}, + opaque_artifacts={"evidence": descriptor["key"]}, + typed_artifacts={"inputs": {}, "outputs": {"evidence": descriptor}}, + ) + release = NodeReceipt( + key="c" * 64, + hit=False, + seed=2, + kernel_ref="release@1", + kernel_impl_hash="d" * 64, + capabilities=_capabilities(KernelRole.RELEASE), + receipt={ + "tier": "evidence", + "outcome": "fail", + "gate_ancestry": 5, + "requires_decisions": [], + }, + typed_artifacts={"inputs": {"donor": descriptor}, "outputs": {}}, + ) + with pytest.raises( + ValueError, match="release node 'release' has invalid gate ancestry 5" + ): + RunManifest( + country="toy", + nodes={"gate": gate, "release": release}, + started_at="t0", + finished_at="t1", + host="h", + ) + + +def test_a_manifest_without_typed_edges_keeps_its_gate_ancestry_diagnostic() -> None: + """Amendment 19 must not change a manifest that declares no byte edges. + + A malformed ``gate_ancestry`` is reported by ``tier`` with the node and + the offending value, exactly as before; the typed-ancestry check has + nothing to say about a manifest with no typed artifacts. + """ + release = NodeReceipt( + key="c" * 64, + hit=False, + seed=2, + kernel_ref="release@1", + kernel_impl_hash="d" * 64, + capabilities=_capabilities(KernelRole.RELEASE), + receipt={"tier": "evidence", "outcome": "fail", "gate_ancestry": 5}, + ) + manifest = RunManifest( + country="toy", + nodes={"release": release}, + started_at="t0", + finished_at="t1", + host="h", + ) + with pytest.raises(ValueError, match="invalid gate ancestry 5"): + assert manifest.tier is None + + +def test_a_malformed_typed_descriptor_is_refused_field_by_field() -> None: + """Amendment 19: foreign provenance is parsed, not trusted. + + ``_validate_typed_ancestry`` rebuilds every recorded descriptor, so a + manifest written elsewhere cannot smuggle one through by omitting a + field, misspelling a type, or claiming an identity its producer key does + not derive. Each arm below is a separate refusal in + ``value_from_descriptor``. + """ + producer_key = "a" * 64 + good = _typed_descriptor("fit", "forest", producer_key) + + def _consumer(descriptor: object) -> None: + NodeReceipt( + key="c" * 64, + hit=False, + seed=2, + kernel_ref="consume@1", + kernel_impl_hash="d" * 64, + capabilities=_capabilities(), + typed_artifacts={"inputs": {"donor": descriptor}, "outputs": {}}, + ) + + _consumer(good) # the control: the well-formed descriptor is accepted + + with pytest.raises(ValueError, match="Malformed typed artifact descriptor"): + _consumer("not a mapping") + with pytest.raises(ValueError, match="Malformed typed artifact descriptor"): + _consumer({name: good[name] for name in good if name != "numerics"}) + with pytest.raises(ValueError, match="Malformed typed artifact descriptor"): + _consumer({**good, "extra": 1}) + with pytest.raises(ValueError, match="producer/name must be nonempty strings"): + _consumer({**good, "producer": ""}) + with pytest.raises(ValueError, match="producer/name must be nonempty strings"): + _consumer({**good, "artifact": 3}) + with pytest.raises(ValueError, match="Malformed typed artifact type"): + _consumer({**good, "type": {"name": "qrf.forest"}}) + with pytest.raises(ValueError, match="Malformed artifact numeric scope"): + _consumer({**good, "numerics": {"numeric": "bitwise"}}) + # The identity is re-derived, not read: a descriptor may not name bytes + # its own producer key does not produce. + with pytest.raises(ValueError, match="identity does not match its producer"): + _consumer({**good, "key": opaque_artifact_key(producer_key, "other")}) + + +def test_a_tolerance_bound_artifact_records_and_rebuilds_its_bound() -> None: + """Amendment 19: a tolerance scope survives the round trip intact.""" + bound = {"rtol": 1e-09, "atol": 0.0, "ulps": 4} + producer_key = "a" * 64 + descriptor = { + **_typed_descriptor("fit", "forest", producer_key), + "numerics": { + "numeric": "tolerance_bound", + "tolerance": bound, + "platform": None, + }, + } + capabilities = Capabilities( + determinism=Determinism.SEEDED, + numeric=graph_api.Numeric.TOLERANCE_BOUND, + seed_source=SeedSource.EXECUTOR, + dependencies=("numpy",), + tolerance=graph_api.Tolerance(**bound), + ) + producer = NodeReceipt( + key=producer_key, + hit=False, + seed=1, + kernel_ref="fit@1", + kernel_impl_hash="b" * 64, + capabilities=capabilities, + receipt={"trees": 3}, + opaque_artifacts={"forest": descriptor["key"]}, + typed_artifacts={"inputs": {}, "outputs": {"forest": descriptor}}, + ) + consumer = NodeReceipt( + key="c" * 64, + hit=False, + seed=2, + kernel_ref="consume@1", + kernel_impl_hash="d" * 64, + capabilities=capabilities, + receipt={"read": True}, + typed_artifacts={"inputs": {"donor": descriptor}, "outputs": {}}, + ) + RunManifest( + country="toy", + nodes={"fit": producer, "draw": consumer}, + started_at="t0", + finished_at="t1", + host="h", + ) + # A bound that is not the three declared fields is not a bound. + with pytest.raises(ValueError, match="Malformed artifact tolerance"): + replace( + consumer, + typed_artifacts={ + "inputs": { + "donor": { + **descriptor, + "numerics": { + "numeric": "tolerance_bound", + "tolerance": {"rtol": 1e-09}, + "platform": None, + }, + } + }, + "outputs": {}, + }, + ) + + +def test_a_scope_claiming_both_platform_and_tolerance_is_refused() -> None: + """Amendment 19: the two numeric classes are alternatives, not a pair. + + ``numeric_scope`` never builds this combination, so only provenance + written elsewhere can carry it — which is exactly why the manifest + re-derives the scope rather than trusting the recorded one. + """ + producer_key = "a" * 64 + descriptor = { + **_typed_descriptor("fit", "forest", producer_key), + "numerics": { + "numeric": "tolerance_bound", + "tolerance": {"rtol": 1e-09, "atol": 0.0, "ulps": 4}, + "platform": "arm64/darwin/py3.13", + }, + } + capabilities = Capabilities( + determinism=Determinism.SEEDED, + numeric=graph_api.Numeric.TOLERANCE_BOUND, + seed_source=SeedSource.EXECUTOR, + dependencies=("numpy",), + tolerance=graph_api.Tolerance(rtol=1e-09, atol=0.0, ulps=4), + ) + producer = NodeReceipt( + key=producer_key, + hit=False, + seed=1, + kernel_ref="fit@1", + kernel_impl_hash="b" * 64, + capabilities=capabilities, + receipt={"trees": 3}, + opaque_artifacts={"forest": descriptor["key"]}, + typed_artifacts={"inputs": {}, "outputs": {"forest": descriptor}}, + ) + consumer = NodeReceipt( + key="c" * 64, + hit=False, + seed=2, + kernel_ref="consume@1", + kernel_impl_hash="d" * 64, + capabilities=capabilities, + receipt={"read": True}, + typed_artifacts={"inputs": {"donor": descriptor}, "outputs": {}}, + ) + with pytest.raises(ValueError, match="combining platform and tolerance scopes"): + RunManifest( + country="toy", + nodes={"fit": producer, "draw": consumer}, + started_at="t0", + finished_at="t1", + host="h", + ) diff --git a/packages/microcosm-graph/tests/test_graph_view.py b/packages/microcosm-graph/tests/test_graph_view.py index ad9f219fe..e27c415b6 100644 --- a/packages/microcosm-graph/tests/test_graph_view.py +++ b/packages/microcosm-graph/tests/test_graph_view.py @@ -3,6 +3,9 @@ from __future__ import annotations from microcosm.graph.decl import ( + ArtifactInput, + ArtifactOutput, + ArtifactType, Graph, Node, Owned, @@ -85,3 +88,38 @@ def test_describe_graph_alone_marks_runtime_facts_unavailable() -> None: assert "" in rendered assert "seed\\0" in rendered assert "Store:" not in rendered + + +def test_describe_shows_declared_artifact_edges_on_one_screen() -> None: + """Amendment 19: byte edges are legible from the graph alone (charter G1).""" + forest = ArtifactType("qrf.forest", 2) + create = Node( + "survey", + "source.frame@1", + sources=("survey",), + outputs=(Owned("person", "age", "int64"),), + structural=StructuralDelta.CREATE, + ) + fit = Node( + "fit", + "toy.fit@1", + inputs=(Slice("person", ("age",)),), + artifact_outputs=(ArtifactOutput("forest", forest),), + ) + draw = Node( + "draw", + "toy.model@1", + inputs=(Slice("person", ("age",)),), + outputs=(Owned("person", "income", "float64"),), + artifact_inputs=(ArtifactInput("donor", "fit", "forest", forest),), + ) + compiled = compile_graph( + Graph("toy", (SourceRef("survey", "csv-tables"),), (create, fit, draw)) + ) + producer = describe(compiled, "fit") + consumer = describe(compiled, "draw") + assert "Artifact outputs: forest (qrf.forest@2)" in producer + assert "Artifact inputs: donor <- fit.forest (qrf.forest@2)" in consumer + assert len(consumer.splitlines()) < 40 + # A node declaring none says nothing about artifacts. + assert "Artifact" not in describe(compiled, "survey")