From 2879fe510973d5b03f5d4d287837e2dbb800c310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:35:55 +0100 Subject: [PATCH 1/7] Add country-neutral Orrery execution evidence and UK contracts --- changelog.d/1080.added.md | 1 + docs/agent-guide.md | 4 +- docs/orrery-adapter.md | 192 ++++- .../build/uk_runtime/full_build_cli.py | 72 +- .../build/uk_runtime/orrery_contract.py | 316 +++++++++ .../microcosm/build/uk_runtime/spine_build.py | 48 +- .../tests/engine_free/uk/test_uk_frs_spine.py | 23 + .../engine_free/uk/test_uk_full_build_cli.py | 21 + .../uk/test_uk_full_calibration_graph.py | 83 +++ .../engine_free/uk/test_uk_national_graph.py | 37 + .../engine_free/uk/test_uk_orrery_contract.py | 139 ++++ .../uk/test_uk_rowwise_national_role.py | 22 + .../src/microcosm/calibrate/graph_evidence.py | 208 ++++++ .../shared/test_ordered_artifacts.py | 18 +- .../src/microcosm/graph/__init__.py | 23 +- .../src/microcosm/graph/evidence.py | 667 ++++++++++++++++++ .../src/microcosm/graph/keys.py | 7 +- .../src/microcosm/graph/orrery.py | 124 +++- .../src/microcosm/graph/orrery_evidence.py | 340 +++++++++ .../src/microcosm/graph/presentation.py | 161 +++++ .../src/microcosm/graph/store.py | 9 +- .../engine_free/shared/test_graph_evidence.py | 426 +++++++++++ .../engine_free/shared/test_graph_executor.py | 21 + tools/orrery-contract/verify-execution.mjs | 14 + 24 files changed, 2925 insertions(+), 51 deletions(-) create mode 100644 changelog.d/1080.added.md create mode 100644 packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_contract.py create mode 100644 packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py create mode 100644 packages/microcosm-calibrate/src/microcosm/calibrate/graph_evidence.py create mode 100644 packages/microcosm-graph/src/microcosm/graph/evidence.py create mode 100644 packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py create mode 100644 packages/microcosm-graph/src/microcosm/graph/presentation.py create mode 100644 packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py create mode 100644 tools/orrery-contract/verify-execution.mjs diff --git a/changelog.d/1080.added.md b/changelog.d/1080.added.md new file mode 100644 index 000000000..24812b4f8 --- /dev/null +++ b/changelog.d/1080.added.md @@ -0,0 +1 @@ +Add country-neutral recorded execution exports for Orrery, with independent execution/cache/gate states, phase history, digest-bound evidence links and aggregate calibration diagnostics. UK drivers preserve native evidence and presentation contracts, including checkpoint boundaries and the final national readback inventory. Orrery snapshots remain opt-in. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 0a7351b97..457a95f53 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -76,7 +76,9 @@ The Orrery parser compatibility assertion lives in engine-free runner installs the supported public Orrery range and locked Node dependency set under `tools/orrery-contract/`; the pytest test generates a document through Microcosm's public Python API and requires Orrery's public -parser to accept it. It performs no browser rendering. +parser to accept it. The recorded-evidence assertion in `test_graph_evidence.py` +uses the same pinned parser for groups, statuses, activities and artifacts. +These CI tests perform no browser rendering. New commits to a PR cancel older unfinished CI runs for that same PR. Each main-push run has a unique concurrency group, so all main-push runs diff --git a/docs/orrery-adapter.md b/docs/orrery-adapter.md index 80e74929b..7192cc7ac 100644 --- a/docs/orrery-adapter.md +++ b/docs/orrery-adapter.md @@ -1,7 +1,8 @@ # Orrery adapter `microcosm.graph.orrery` converts a Microcosm graph declaration and its -compiler-derived metadata into `graph-explorer/v1`, the JSON format accepted by +compiler-derived metadata, with optional recorded execution evidence, into +`graph-explorer/v1`, the JSON format accepted by [Orrery](https://github.com/TheAxiomFoundation/orrery). Microcosm owns the calculation and field semantics. Orrery owns graph navigation, presentation, and self-contained HTML export. @@ -13,8 +14,8 @@ This conversion is separate from graph execution: uses this `CompiledGraph`. 2. `graph_schema(compiled)` derives a portable static metadata artifact from that same `CompiledGraph`. -3. `orrery_json(compiled)` converts the static metadata into Orrery input. It - does not execute the graph. +3. `orrery_json(compiled, execution=...)` converts the metadata and any supplied + evidence into Orrery input. Omitting `execution` keeps declaration-only export. 4. The separately installed Orrery command can turn that JSON into a self-contained HTML file. @@ -23,6 +24,9 @@ Microcosm executes. The direct API performs steps 2 and 3 in memory. A saved schema lets another process repeat step 3 without importing the original graph construction code. +A kernel is the implementation of one graph operation, with a declared input, +output, numerical and execution contract. + ## Export from Python Use the public API when the `Graph` or `CompiledGraph` is already available: @@ -92,7 +96,176 @@ npx orrery --input orrery.json --output orrery.html Orrery validates the JSON before export. Its HTML contains the viewer assets and does not require a network connection to render. Microcosm CI parses a generated document with the compatible range declared by its contract-test -package and a locked set of transitive dependencies. +package and a locked set of transitive dependencies. The consumer contract is +`@axiom-foundation/orrery >=0.6.1 <0.7.0`, pinned by +`tools/orrery-contract/package-lock.json` for CI. + +## Country inputs and presentation + +The schema and exporter retain the interfaces introduced by #888. The graph +schema remains `microcosm.graph.schema.v1`; compiler facts still come from the +same compiler. Country is descriptive metadata. Shared export code neither +imports a country builder nor dispatches on country names or operation prefixes. + +Countries can supply `extensions["microcosm.presentation"]` with protocol +`microcosm.graph.presentation.v1`: + +```python +from microcosm.graph import graph_schema + +presentation = { + "protocol": "microcosm.graph.presentation.v1", + "scope": {"id": "national", "label": "National build"}, + "groups": [ + {"id": "sources", "label": "Sources", "sources": ["survey"]}, + {"id": "enrichment", "label": "Enrichment", "operations": ["impute"]}, + ], + "operations": {"impute": {"description": "Fit and draw as one execution unit"}}, +} +schema = graph_schema( + compiled, extensions={"microcosm.presentation": presentation} +) +``` + +Group members name real operations or sources. Optional `parent` names another +group. Groups use Orrery containment and preserve every dependency edge. Field +nodes inherit their provider's group. The validator rejects unknown members, +duplicate membership and containment cycles. Operation/source records can +provide `label`, `description`, `composite` metadata and `references`, each with +a `label` and optional HTTP(S)/relative `url` and `sha256`. + +Scope boundaries name an `operation` and `kind`, with `upstream` references +requiring both URL and digest, or a `missing` reason. UK inputs identify raw +spine, full dense, checkpoint dense and national scopes. Composite descriptions +come from the maintained UK operation inventory; a coupled fit/draw stays one +operation and one cache boundary. + +The UK spine driver saves its producing declaration, compiler schema, run +manifest and execution binding. Its checkpoint sidecar records immutable +per-attempt graph/manifest links and byte digests. Downstream UK schemas copy +these recorded bytes into `upstream-.json` files and use relative links. +Old checkpoints expose missing provenance explicitly. The exporter never +reconstructs an earlier spine from current source code or matching field names. + +## Recorded execution + +Capture evidence immediately after execution, while the executor's run-end +source identities are available: + +```python +from microcosm.graph import save_run_evidence + +index = save_run_evidence( + compiled, manifest, store=store, directory=Path("attempt-evidence"), + attempt_id="my-attempt", phase="numerical", + artifact_summaries=summary_providers, +) +``` + +`summary_providers` is optional. It maps `(artifact_type_name, schema_version)` +to a callback accepting `ArtifactSummaryContext` and returning JSON aggregates. +`microcosm.calibrate.graph_evidence.CALIBRATION_SUMMARY_PROVIDERS` uses maintained +problem, solution and result codecs. The UK adapter extends that registry with +recorded target uncertainty and size-search/draw/refit summaries. No country +lookup occurs in the shared exporter. Native summaries are captured once per +artifact and reused across later phases. + +Providers are trusted projections: they must emit aggregates and omit +record-level values. + +`save_run_evidence` appends an `execution.evidence.json` index with protocol +`microcosm.graph.execution-input.v1`, plus exact graph, manifest, binding and +summary files. `microcosm.graph.run-binding.v1` records the graph/manifest byte +digests, manifest key, attempt, phase, platform and identities of used sources. +These native files support later export; they do not create an Orrery snapshot. +A serialized historical manifest without this binding cannot supply missing +run-end source identities by reading today's source files. + +Read and export an existing bundle without executing kernels: + +```python +from microcosm.graph import ContentStore, collect_execution_evidence, load_run_evidence +from microcosm.graph.orrery import orrery_json_from_schema + +store = ContentStore(Path("build/.graph-store"), create=False) +runs = load_run_evidence( + Path("build/execution.evidence.json"), store=store, + reference_base=Path("build"), +) +execution = collect_execution_evidence(schema, runs=runs, store=store) +Path("build/graph.orrery.json").write_text( + orrery_json_from_schema(schema, execution=execution), encoding="utf-8" +) +``` + +The equivalent command keeps the existing input/output structure: + +```sh +uv run python -m microcosm.graph.orrery \ + --schema build/graph.schema.json \ + --evidence build/execution.evidence.json \ + --store build/.graph-store \ + --output build/graph.orrery.json +npx orrery --input build/graph.orrery.json --output build/graph.orrery.html +``` + +`--evidence` and `--store` must appear together. +Native evidence links and saved-schema relative references are rebased to the +command's output directory; keep the referenced files available with the HTML. +UK full/national drivers save +`graph.schema.json`, `execution.evidence.json` and flat `evidence-*.json` files +beside `graph.json` and `operations.json`. Durable copies also remain in the +content store's attempt directory if a later phase interrupts the build. For +that case, pass the attempt's `graph.schema.json` and +`graph-evidence/execution.evidence.json`, and the same store. The spine sidecar +identifies its native index under the checkpoint directory; use that directory's +`graph.schema.json` and `node-graph` store. Orrery JSON and HTML remain opt-in. + +The final national schema, declaration and inventory all include the appended +`uk.full.national.readback` operation. Earlier endpoint runs remain separate +phases of the same attempt. A final cache hit does not erase earlier computation. + +Validation recompiles each recorded graph, compares declarations and dependency +sets to the exported graph, recomputes receipt/artifact identities using recorded +inputs and platform, and checks required store payload digests. Graph/manifest +mismatches, missing required bytes and corrupt files refuse export. Unavailable +gate-blocked outputs, omitted operations and unconfigured summary providers are +explicit. Earlier phases not supplied to the exporter remain unknown. + +## Orrery evidence panels + +| Surface | Exported evidence | +| --- | --- | +| Record | Operation declarations, attempt/phase history, duration, numerical keys, mass and weight transitions, calibration/size aggregates | +| Sources | Digest-bound native graph/manifest/binding/summary links and recorded checkpoint references | +| Activity | One record per operation and supplied phase, declared operation/source inputs, artifact references and phase timestamps | +| Status badges | Independent execution, cache and gate states, including missing/unreached and evidence-absent outcomes | + +Per-target tables contain target, initial and achieved values, residuals, +relative error, tolerance and recorded uncertainty where available. Full tables +appear once in `metadata.execution.summaries`; selected-node Record data shows +an explicit 50-row preview with `total_rows` and `preview_limit`. Weight summaries +contain counts, mass, quantiles and effective sample size. They contain no entity +axes, record values, per-record weights, draw masks or fitted model bytes. + +Orrery's existing panels can present these records. A dedicated calibration table, +phase selector, linked upstream-run expansion and group-focused layout controls +remain viewer follow-ups; the exporter does not add those UI features. Precise +per-output mathematical lineage remains #865. + +Orrery 0.6.1's Activity panel includes downstream activities that received the +selected record as an input. This can produce a long list for structural nodes; +the artifact labels include their producing operation and output name so they +remain identifiable. Browsing a synthetic saved national run confirmed groups, +Record fields, separate status badges, Sources links and phase activities. + +Measured with the maintained full UK declaration, calibration year 2025 and +legacy geography assignment: dense export has 5,148 presentation nodes, 18,135 +edges and 18,174,094 JSON bytes; the optional size path has 5,408 nodes, 19,702 +edges and 19,588,363 bytes. A synthetic three-household calibration with 22,053 +targets produced a 5,346,758-byte execution snapshot retaining every target row. +These are declaration and synthetic diagnostic measurements, not a licensed +population run. Existing limits stay unchanged. ## Fields and input bindings @@ -164,11 +337,13 @@ pre-rewrite value and final rewritten value cannot collide. ## Evidence and limits -The export contains static declarations only. It does not contain entity IDs, +Declaration-only export contains static declarations. It does not contain entity IDs, memberships, field values, source bytes, kernel code, observed runtime reads, cache results, execution receipts, verifier assessments, or release decisions. Content revisions identify metadata bytes; they are not execution keys or -authenticity claims. +authenticity claims. Execution export adds the recorded aggregates above, but +does not emit Orrery cryptographic `Receipt` verification verdicts. Checking keys +and byte digests does not establish signatures or release certification. Python integers outside JavaScript's safe range are represented as `{"integer_literal": "..."}` before Orrery parses the document. Large integral @@ -177,6 +352,5 @@ excessive nesting, non-JSON objects, more than 20,000 presentation nodes or 100,000 relationships, input above 32 MiB, and output above 64 MiB. They fail instead of silently omitting records. -Runtime receipts and precise per-output value lineage could be added by a -separate runtime-aware adapter in the future. They cannot be inferred from the -static compiler schema and are intentionally absent here. +Runtime evidence comes only from supplied records. Precise per-output value +lineage cannot be inferred from static reads or operation ancestry. diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/full_build_cli.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/full_build_cli.py index 11d1d413d..a761223a4 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/full_build_cli.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/full_build_cli.py @@ -152,6 +152,7 @@ write_uk_national_frame, ) from .national_sampling import UK_SAMPLE_RUNG_TOKENS +from .orrery_contract import persist_uk_run_evidence, save_uk_graph_schema from .rowwise_cli import ( MANIFEST_FILENAME, REPOSITORY, @@ -935,12 +936,13 @@ def _materialize_evidence(manifest, store, out: Path) -> dict: return inventory -def _persist_checkpoint(manifest, store, args, phase: str) -> None: +def _persist_checkpoint(manifest, store, args, phase: str, *, graph: Graph) -> None: """Keep receipts and small evidence durable if a later kernel refuses.""" payload = manifest.to_json_bytes() materialize_bytes(payload, args.out / f"{phase}.graph.json") attempt = Path(args.attempt_evidence) materialize_bytes(payload, attempt / f"{phase}.graph.json") + persist_uk_run_evidence(graph, manifest, store, args, phase=phase) evidence = {} for node_id, receipt in manifest.nodes.items(): for name, key in receipt.opaque_artifacts.items(): @@ -959,6 +961,17 @@ def _persist_checkpoint(manifest, store, args, phase: str) -> None: ) +def _save_graph_presentation(graph: Graph, args, *, scope: str) -> None: + sidecar = getattr(args, "input_sidecar", None) + input_h5 = getattr(args, "input_h5", None) + if sidecar is None and input_h5 is not None: + sidecar = input_h5.with_suffix(".build.json") + save_uk_graph_schema(graph, args.out, scope=scope, sidecar_path=sidecar) + save_uk_graph_schema( + graph, Path(args.attempt_evidence), scope=scope, sidecar_path=sidecar + ) + + def _argument_sources(args: argparse.Namespace) -> dict[str, Path]: """The path-valued request arguments, for the output-location check before (or without) a prepared build.""" @@ -1284,6 +1297,11 @@ def _execute_full_build( args.out.mkdir(parents=True, exist_ok=True) store = ContentStore(args.graph_store or args.out / ".graph-store") graph = full.graph + _save_graph_presentation( + graph, + args, + scope="checkpoint_dense" if args.input_h5 is not None else "full_dense", + ) materialize_bytes(graph_to_json(graph).encode(), args.out / "graph.json") materialize_bytes( canonical_json(full.operation_inventory()), args.out / "operations.json" @@ -1309,7 +1327,9 @@ def _execute_full_build( kernels=kernels, resume=resume, ) - _persist_checkpoint(checkpoint, store, args, endpoint) + _persist_checkpoint( + checkpoint, store, args, endpoint, graph=_through(graph, endpoint) + ) resume = "require" if args.resume == "require" else "auto" # Persist preflight outcomes before any solver can reject them. stage(telemetry, "target_compilation", "started") @@ -1321,7 +1341,7 @@ def _execute_full_build( kernels=kernels, resume=resume, ) - _persist_checkpoint(preflight, store, args, "preflight") + _persist_checkpoint(preflight, store, args, "preflight", graph=preflight_graph) _materialize_evidence(preflight, store, args.out) if "uk.full.target_selection" in preflight.nodes: selection = json.loads( @@ -1357,7 +1377,13 @@ def _execute_full_build( kernels=kernels, resume="require" if args.resume == "require" else "auto", ) - _persist_checkpoint(manifest, store, args, "numerical") + _persist_checkpoint( + manifest, + store, + args, + "numerical", + graph=_through(graph, "uk.full.gates.calibrated"), + ) _materialize_evidence(manifest, store, args.out) _require_gate_kernel_completed(manifest, "uk.full.gates.calibrated") terminal_files = materialize_uk_terminal_artifacts( @@ -1434,6 +1460,7 @@ def _execute_full_build( kernels=kernels, resume="require" if args.resume == "require" else "auto", ) + _persist_checkpoint(manifest, store, args, "export", graph=graph) descriptor = json.loads( _payload(manifest, store, "uk.full.export.prepare", "export_descriptor") ) @@ -1520,6 +1547,16 @@ def _execute_full_build( resume="auto", ) final.save(args.out / "build.graph.json") + _persist_checkpoint(final, store, args, "final", graph=graph) + _save_graph_presentation( + graph, + args, + scope="checkpoint_dense" if args.input_h5 is not None else "full_dense", + ) + materialize_bytes( + canonical_json(replace(full, graph=graph).operation_inventory()), + args.out / "operations.json", + ) materialize_bytes(graph_to_json(graph).encode(), args.out / "graph.json") _materialize_evidence(final, store, args.out) package = json.loads(_payload(final, store, "uk.full.package", "package_inventory")) @@ -1882,6 +1919,7 @@ def _execute_national_build( args.out.mkdir(parents=True, exist_ok=True) store = ContentStore(args.graph_store or args.out / ".graph-store") graph = national.graph + _save_graph_presentation(graph, args, scope="national") materialize_bytes(graph_to_json(graph).encode(), args.out / "graph.json") materialize_bytes( canonical_json(national.operation_inventory()), args.out / "operations.json" @@ -1900,7 +1938,13 @@ def _execute_national_build( kernels=kernels, resume=resume, ) - _persist_checkpoint(checkpoint, store, args, "uk.full.spine_checkpoint") + _persist_checkpoint( + checkpoint, + store, + args, + "uk.full.spine_checkpoint", + graph=_through(graph, "uk.full.spine_checkpoint"), + ) resume = "require" if args.resume == "require" else "auto" spine_frame = checkpoint.population("uk.full.spine_checkpoint") stage( @@ -1921,6 +1965,9 @@ def _execute_national_build( kernels=kernels, resume=resume, ) + _persist_checkpoint( + targets, store, args, "targets", graph=_through(graph, NATIONAL_TARGETS_NODE) + ) registry_document = json.loads( graph_payload(targets, store, NATIONAL_TARGETS_NODE, "registry") ) @@ -1974,7 +2021,13 @@ def _execute_national_build( kernels=kernels, resume=resume, ) - _persist_checkpoint(manifest, store, args, "numerical") + _persist_checkpoint( + manifest, + store, + args, + "numerical", + graph=_through(graph, "uk.full.gates.calibrated"), + ) _materialize_evidence(manifest, store, args.out) _require_gate_kernel_completed(manifest, NATIONAL_GATES_NODE) if state is not None: @@ -2129,6 +2182,13 @@ def _execute_national_build( kernels=kernels, resume="auto", ) + _persist_checkpoint(final, store, args, "final", graph=continued) + _save_graph_presentation(continued, args, scope="national") + materialize_bytes(graph_to_json(continued).encode(), args.out / "graph.json") + materialize_bytes( + canonical_json(replace(national, graph=continued).operation_inventory()), + args.out / "operations.json", + ) _require_gate_kernel_completed(final, NATIONAL_READBACK_NODE) readback = json.loads( graph_payload(final, store, NATIONAL_READBACK_NODE, "readback") diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_contract.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_contract.py new file mode 100644 index 000000000..5f86eb775 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_contract.py @@ -0,0 +1,316 @@ +"""UK inputs to the shared presentation and execution-evidence contracts.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np + +from microcosm.calibrate.artifacts import RESULT_TYPE, decode_problem +from microcosm.calibrate.graph_evidence import ( + CALIBRATION_SUMMARY_PROVIDERS, + result_summary, +) +from microcosm.graph import compile_graph, graph_schema, save_run_evidence +from microcosm.graph.canonical import canonical_json +from microcosm.graph.evidence import ArtifactSummaryContext, read_json, sha256 +from microcosm.graph.presentation import PRESENTATION_EXTENSION, PRESENTATION_PROTOCOL + +from .graph import uk_spine_operation_inventory +from .graph_calibration import SIZE_DRAW_TYPE, SIZE_RECEIPT_TYPE, SIZE_SEARCH_TYPE +from .graph_national import NATIONAL_TARGET_TYPE +from .graph_targets import TARGET_SELECTION_TYPE, registry_from_payload + + +def _uk_result_summary(context: ArtifactSummaryContext) -> dict: + data = result_summary(context) + ancestors, pending = set(), [context.node_id] + while pending: + current = pending.pop() + if current in ancestors: + continue + ancestors.add(current) + pending.extend(context.run.compiled.predecessors[current]) + specs = {} + for node_id in ancestors: + receipt = context.run.manifest.nodes.get(node_id) + if receipt is None: + continue + for name, descriptor in receipt.typed_artifacts.get("outputs", {}).items(): + if name not in receipt.opaque_artifacts: + continue + type_ = descriptor["type"] + if type_ == { + "name": TARGET_SELECTION_TYPE.name, + "schema_version": TARGET_SELECTION_TYPE.schema_version, + }: + raw = json.loads(context.store.load_bytes(descriptor["key"]))[ + "registry" + ] + elif type_ == { + "name": NATIONAL_TARGET_TYPE.name, + "schema_version": NATIONAL_TARGET_TYPE.schema_version, + }: + raw = json.loads(context.store.load_bytes(descriptor["key"]))[ + "national_registry" + ] + else: + continue + for spec in registry_from_payload(raw).specs: + row_name = spec.to_target().row_name + if row_name in specs and specs[row_name] != spec: + raise ValueError("Conflicting recorded target specifications.") + specs[row_name] = spec + for row in data["tables"]["targets"]: + spec = specs.get(row["name"]) + if spec is None: + continue + if row["target"] != spec.value: + raise ValueError("Recorded target registry differs from the result.") + row["source_entity"] = spec.entity + row["family"] = spec.family + row["uncertainty"] = ( + {"status": "recorded", "se": spec.se} + if spec.se is not None + else {"status": "not_recorded"} + ) + return data + + +def _size_summary(context: ArtifactSummaryContext) -> dict: + """Project the maintained size artifact, excluding IDs, masks and draws.""" + value = json.loads(context.payload) + problem = decode_problem(context.input_payload("problem")) + if value.get("problem_sha256") != problem.sha256: + raise ValueError("Size summary belongs to a different ordered problem.") + method = value.get("method") + if method not in { + "full_pool", + "contribution_informed_l0", + "exact_count", + "contribution_informed_l0_exact_count_refit", + }: + raise ValueError("Unknown recorded UK size method.") + fields = ( + "method", + "requested_households", + "realized_households", + "pool_households", + "households", + "seed", + "protected_carriers", + "certainty_share", + "boundary_draws", + "zero_target_rows", + "selection_pi_hi", + "selection_search_pi_hi", + "selection_reused", + "selection_budget_basis", + "selection_l0_lambda", + "selection_epochs", + "refit_epochs", + "refit_baseline", + "stretch_reference", + "baseline_pi_floor", + "baseline_floored_rows", + "baseline_mass_share_certainties", + "dense_loss", + "compact_loss", + "max_target_scaled_change", + "certification", + "pi_hi", + ) + summary = {key: value[key] for key in fields if key in value} + summary["problem_sha256"] = problem.sha256 + for key in ("support", "protected", "household_ids", "pool_row_indices"): + if key in value: + summary[f"{key}_count"] = len(value[key]) + if "inclusion_probabilities" in value: + probabilities = np.asarray(value["inclusion_probabilities"], dtype=float) + if ( + probabilities.ndim != 1 + or not np.isfinite(probabilities).all() + or (probabilities <= 0).any() + or (probabilities > 1).any() + ): + raise ValueError("Invalid recorded inclusion probabilities.") + summary["inclusion_probability_range"] = { + "minimum": float(probabilities.min()), + "maximum": float(probabilities.max()), + } + return {"overview": summary} + + +UK_SUMMARY_PROVIDERS = { + **CALIBRATION_SUMMARY_PROVIDERS, + (RESULT_TYPE.name, RESULT_TYPE.schema_version): _uk_result_summary, + (SIZE_SEARCH_TYPE.name, SIZE_SEARCH_TYPE.schema_version): _size_summary, + (SIZE_DRAW_TYPE.name, SIZE_DRAW_TYPE.schema_version): _size_summary, + (SIZE_RECEIPT_TYPE.name, SIZE_RECEIPT_TYPE.schema_version): _size_summary, +} + + +def checkpoint_references(sidecar_path: Path | None) -> tuple[list[dict], str | None]: + """Use only recorded checkpoint lineage, never today's reconstructed spine.""" + if sidecar_path is None or not sidecar_path.is_file(): + return [], "The checkpoint has no recorded producing graph and manifest." + sidecar, _ = read_json(sidecar_path) + refs = [] + missing = [] + recorded_files = [ + ("graph_declaration", "Producing graph"), + ("graph_manifest", "Producing run manifest"), + ] + if "graph_schema" in sidecar: + recorded_files.append(("graph_schema", "Producing compiler schema")) + for name, label in recorded_files: + record = sidecar.get(name) + if ( + not isinstance(record, dict) + or not record.get("path") + or not record.get("sha256") + ): + missing.append(label) + continue + path = Path(record["path"]) + if not path.is_absolute(): + path = sidecar_path.parent / path + if not path.is_file(): + missing.append(f"{label} bytes unavailable") + elif sha256(read_json(path)[1]) != record["sha256"]: + raise ValueError(f"Recorded checkpoint {label.lower()} digest mismatch.") + refs.append( + {"label": label, "url": str(path.resolve()), "sha256": record["sha256"]} + ) + return refs, "; ".join(missing) if missing else None + + +def uk_graph_presentation( + graph, *, scope: str, sidecar_path: Path | None = None, spec=None +) -> dict: + """Country-owned labels and grouping; shared code owns contract validation.""" + groups = { + key: [] + for key in ( + "enrichment", + "geography", + "targets", + "calibration", + "checks", + "export", + ) + } + operations = {} + ids = {node.id for node in graph.nodes} + # Composite descriptions come from the maintained executable stage roster. + if "create_uk_frs" in ids: + for item in uk_spine_operation_inventory(graph, spec): + operations[item["node"]] = {"composite": item} + for node in graph.nodes: + if "gates" in node.id or "readback" in node.id or "certification" in node.id: + group = "checks" + elif node.id.startswith("uk.full.export") or node.id == "uk.full.package": + group = "export" + elif node.id.startswith( + ("uk.full.dense", "uk.full.size", "uk.full.selected", "uk.full.calibrated") + ): + group = "calibration" + elif "target" in node.id or "problem" in node.id: + group = "targets" + elif node.id.startswith("uk.full.") and any( + part in node.id for part in ("geograph", "clone", "support", "atomic") + ): + group = "geography" + else: + group = "enrichment" + groups[group].append(node.id) + scope_record = {"id": scope, "label": scope.replace("_", " ")} + if "uk.full.spine_checkpoint" in ids and any( + source.name == "uk_spine" for source in graph.sources + ): + refs, missing = checkpoint_references(sidecar_path) + boundary = { + "operation": "uk.full.spine_checkpoint", + "kind": "checkpoint", + "upstream": refs, + } + if missing: + boundary["missing"] = missing + scope_record["boundaries"] = [boundary] + return { + "protocol": PRESENTATION_PROTOCOL, + "scope": scope_record, + "groups": [ + { + "id": "sources", + "label": "Sources", + "sources": [source.name for source in graph.sources], + }, + *( + {"id": key, "label": key.capitalize(), "operations": members} + for key, members in groups.items() + if members + ), + ], + "operations": operations, + } + + +def save_uk_graph_schema( + graph, directory: Path, *, scope: str, sidecar_path: Path | None = None, spec=None +) -> Path: + contract = uk_graph_presentation( + graph, scope=scope, sidecar_path=sidecar_path, spec=spec + ) + # Copy only explicitly recorded graph/manifest bytes, so links survive the + # temporary build directory's publication and the HTML can sit beside them. + for boundary in contract["scope"].get("boundaries", []): + for ref in boundary["upstream"]: + source = Path(ref["url"]) + if source.is_file(): + relative = Path(f"upstream-{ref['sha256']}.json") + destination = Path(directory) / relative + destination.parent.mkdir(parents=True, exist_ok=True) + payload = read_json(source)[1] + if sha256(payload) != ref["sha256"]: + raise ValueError("Checkpoint reference changed during capture.") + destination.write_bytes(payload) + ref["url"] = relative.as_posix() + schema = graph_schema( + compile_graph(graph), extensions={PRESENTATION_EXTENSION: contract} + ) + path = Path(directory) / "graph.schema.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(canonical_json(schema)) + return path + + +def persist_uk_run_evidence(graph, manifest, store, args, *, phase: str) -> Path: + """Preserve native evidence; the operator separately requests Orrery export.""" + attempt_id = Path(args.attempt_evidence).name + durable = Path(args.attempt_evidence) / "graph-evidence" + index_path = save_run_evidence( + compile_graph(graph), + manifest, + store=store, + directory=durable, + attempt_id=attempt_id, + phase=phase, + artifact_summaries=UK_SUMMARY_PROVIDERS, + ) + # The build publisher transacts over files in one directory. Keep the + # published evidence flat, while retaining the durable attempt bundle. + index, _ = read_json(index_path) + for entry in index["runs"]: + for field in ("graph", "manifest", "binding", "summaries"): + relative = Path(entry[field]["path"]) + filename = f"evidence-{attempt_id}-{relative.parent.name}-{field}.json" + payload = read_json(durable / relative)[1] + if sha256(payload) != entry[field]["sha256"]: + raise ValueError("Native phase evidence changed during capture.") + (args.out / filename).write_bytes(payload) + entry[field]["path"] = filename + destination = args.out / "execution.evidence.json" + destination.write_bytes(canonical_json(index)) + return destination diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py index d7d805a9b..c02e4a6bb 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py @@ -7,6 +7,7 @@ import json import sys import time +import uuid from collections.abc import Mapping from dataclasses import dataclass from datetime import UTC, datetime @@ -131,6 +132,7 @@ UK_SAMPLE_SEED_DEFAULT, ) from microcosm.build.uk_runtime.nts_bus_travel import UKNTSBusTravelStageTransform +from microcosm.build.uk_runtime.orrery_contract import save_uk_graph_schema from microcosm.build.uk_runtime.pension_credit_take_up import ( UKPensionCreditTakeUpStageTransform, ) @@ -168,7 +170,13 @@ from microcosm.build.uk_runtime.was_wealth import UKWASWealthStageTransform from microcosm.frame import Frame from microcosm.frame.adapters.policyengine_uk import PolicyEngineUKEngine -from microcosm.graph import ContentStore, compile_graph, run_graph +from microcosm.graph import ( + ContentStore, + compile_graph, + graph_to_json, + run_graph, + save_run_evidence, +) _PIPELINE = "uk-frs-spine" _REPOSITORY = next( @@ -1809,6 +1817,29 @@ def main(argv: list[str] | None = None) -> int: decisions=(), ) graph_manifest.save(checkpoint_root / "spine.graph.json") + graph_declaration_path = checkpoint_root / "spine.graph-declaration.json" + graph_declaration_path.write_text(graph_to_json(graph), encoding="utf-8") + save_uk_graph_schema(graph, checkpoint_root, scope="raw_spine", spec=spec) + graph_evidence_path = save_run_evidence( + compiled_graph, + graph_manifest, + store=graph_store, + directory=checkpoint_root + / "graph-evidence" + / (graph_attempt_id := uuid.uuid4().hex), + attempt_id=graph_attempt_id, + phase="spine", + ) + graph_run_reference = json.loads(graph_evidence_path.read_text())["runs"][-1] + graph_declaration_path = ( + graph_evidence_path.parent / graph_run_reference["graph"]["path"] + ) + graph_manifest_path = ( + graph_evidence_path.parent / graph_run_reference["manifest"]["path"] + ) + graph_schema_path = save_uk_graph_schema( + graph, graph_evidence_path.parent, scope="raw_spine", spec=spec + ) final_version = compiled_graph.versions[stage_names[-1]] frame = graph_manifest.population(final_version) records = _graph_stage_records( @@ -1921,8 +1952,21 @@ def main(argv: list[str] | None = None) -> int: ) sidecar["operation_inventory"] = list(uk_spine_operation_inventory(graph, spec)) sidecar["graph_manifest"] = { - "path": str(checkpoint_root / "spine.graph.json"), + "path": str(graph_manifest_path.resolve()), "key": graph_manifest.key, + "sha256": hashlib.sha256(graph_manifest_path.read_bytes()).hexdigest(), + } + sidecar["graph_declaration"] = { + "path": str(graph_declaration_path.resolve()), + "sha256": hashlib.sha256(graph_declaration_path.read_bytes()).hexdigest(), + } + sidecar["graph_schema"] = { + "path": str(graph_schema_path.resolve()), + "sha256": hashlib.sha256(graph_schema_path.read_bytes()).hexdigest(), + } + sidecar["graph_execution_evidence"] = { + "path": str(graph_evidence_path.resolve()), + "sha256": hashlib.sha256(graph_evidence_path.read_bytes()).hexdigest(), } stage_evidence = stored_evidence["stage_evidence"] if stage_evidence: diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_spine.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_spine.py index 88a55f868..b240fe84f 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_spine.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_spine.py @@ -1564,6 +1564,29 @@ def test_driver_writes_spine_h5_sidecars_and_logbook( } <= set(frame.table("household")) sidecar = json.loads(output.with_suffix(".build.json").read_text()) assert sidecar["pipeline"] == "uk-frs-spine" + for name in ( + "graph_declaration", + "graph_manifest", + "graph_schema", + "graph_execution_evidence", + ): + reference = sidecar[name] + evidence_path = Path(reference["path"]) + assert "graph-evidence" in evidence_path.parts + assert ( + hashlib.sha256(evidence_path.read_bytes()).hexdigest() + == reference["sha256"] + ) + from microcosm.graph import ContentStore, load_run_evidence + + index_path = Path(sidecar["graph_execution_evidence"]["path"]) + runs = load_run_evidence( + index_path, + store=ContentStore(index_path.parents[2] / "node-graph", create=False), + ) + assert set(runs[0].binding["source_identities"]) == { + name for node in runs[0].compiled.graph.nodes for name in node.sources + } assert sidecar["schema_version"] == 2 assert sidecar["stages"] == list(tool._uk_spine_stage_names(_synthetic_spec(stage))) assert sidecar["uk_frame_content_identity"] == uk_frame_content_identity(frame) diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_full_build_cli.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_full_build_cli.py index a53eb8161..b61269dc6 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_full_build_cli.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_full_build_cli.py @@ -348,6 +348,27 @@ def test_cli_cold_and_required_replay_recreate_dataset_and_sidecars( assert expected["readback_passed"] is True assert expected["release_authorized"] is False assert expected["release_role"] == "dense" + from microcosm.graph import ( + ContentStore, + collect_execution_evidence, + load_run_evidence, + ) + from microcosm.graph.orrery import orrery_document_from_schema + + schema = json.loads((out / "graph.schema.json").read_bytes()) + store = ContentStore(out / ".graph-store", create=False) + overlay = collect_execution_evidence( + schema, + runs=load_run_evidence(out / "execution.evidence.json", store=store), + store=store, + ) + snapshot = orrery_document_from_schema(schema, execution=overlay) + assert any( + json.loads(node["id"]) == ["operation", "uk.full.export.readback"] + for node in snapshot["nodes"] + ) + assert [phase["phase"] for phase in overlay["phases"]][-2:] == ["export", "final"] + assert not list(out.glob("*.orrery.json")) assert (out / f"{STEM}.targets.csv").read_text().startswith("name,target_name") for path in out.iterdir(): if path.is_file(): diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py index 75ecec963..fa44e575d 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py @@ -263,6 +263,89 @@ def binding_solver(): return value +@pytest.mark.parametrize("k", [None, 2, 3]) +def test_orrery_summary_replays_dense_and_size_artifacts_without_solving( + k, tmp_path, monkeypatch +): + from microcosm.build.uk_runtime.orrery_contract import UK_SUMMARY_PROVIDERS + from microcosm.graph import ( + collect_execution_evidence, + graph_schema, + orrery_document, + record_run_binding, + ) + + graph, endpoints = compiled(k) + fixture = tmp_path / "fixture" + fixture.write_bytes(b"fixture") + store = ContentStore(tmp_path / "store") + manifest = run_graph( + graph, sources={"fixture": fixture}, store=store, kernels=registry() + ) + expected = restore_uk_graph_result( + source_frame(), + problem_payload=store.load_bytes( + manifest.nodes[endpoints.problem_producer].opaque_artifacts["problem"] + ), + result_payload=store.load_bytes( + manifest.nodes[endpoints.result_producer].opaque_artifacts["result"] + ), + solution_payload=store.load_bytes( + manifest.nodes[endpoints.solution_producer].opaque_artifacts["solution"] + ), + original_problem_payload=problem_payload(), + ) + import microcosm.calibrate.solve as solver + + monkeypatch.setattr( + solver, "calibrate", lambda *a, **kw: pytest.fail("export reran solver") + ) + run = record_run_binding(graph, manifest, attempt_id="fixture", phase="numerical") + evidence = collect_execution_evidence( + graph_schema(graph), + runs=[run], + store=store, + artifact_summaries=UK_SUMMARY_PROVIDERS, + ) + summary = next( + item["data"] + for item in evidence["summaries"] + if item["node"] == endpoints.result_producer and item["artifact"] == "result" + ) + rows = summary["tables"]["targets"] + assert rows[0]["target"] == expected.diagnostics[0].target + assert rows[0]["achieved"] == expected.diagnostics[0].final_estimate + assert ( + rows[0]["residual"] + == expected.diagnostics[0].final_estimate - expected.diagnostics[0].target + ) + assert rows[0]["uncertainty"]["status"] == "not_recorded" + assert summary["overview"]["final_weights"]["total"] == float( + expected.weights.sum() + ) + assert summary["overview"]["final_weights"]["records"] == (3 if k is None else k) + if k is not None: + size = next( + item["data"]["overview"] + for item in evidence["summaries"] + if item["artifact"] == "size" + ) + assert size["method"] == ( + "full_pool" if k == 3 else "contribution_informed_l0_exact_count_refit" + ) + serialized = json.dumps(evidence) + assert '"household_ids":' not in serialized + assert '"pool_row_indices":' not in serialized + assert '"inclusion_probabilities":' not in serialized + document = orrery_document(graph, execution=evidence) + result_node = next( + node + for node in document["nodes"] + if json.loads(node["id"]) == ["operation", endpoints.result_producer] + ) + assert result_node["data"]["artifact_summaries"] + + def test_reused_draw_skips_rng_and_rejects_changed_binding(monkeypatch): frame = source_frame() dense = calibrate(frame, targets(), epochs=2) diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_national_graph.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_national_graph.py index 0b5d20947..8c87e5e1b 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_national_graph.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_national_graph.py @@ -411,6 +411,43 @@ def test_national_solve_is_the_seam_solve(tmp_path, monkeypatch): assert calibrated.mass_log[:-1] == frame.mass_log +def test_national_orrery_uses_shared_codec_provider(tmp_path, monkeypatch): + from microcosm.calibrate.graph_evidence import CALIBRATION_SUMMARY_PROVIDERS + from microcosm.graph import ( + collect_execution_evidence, + graph_schema, + record_run_binding, + ) + + national, manifest, store, frame, registry = _run( + tmp_path, monkeypatch, endpoint="uk.full.calibrated" + ) + from dataclasses import replace + + phase_graph = replace( + national.graph, + nodes=tuple(node for node in national.graph.nodes if node.id in manifest.nodes), + ) + run = record_run_binding( + compile_graph(phase_graph), manifest, attempt_id="national", phase="numerical" + ) + evidence = collect_execution_evidence( + graph_schema(compile_graph(national.graph)), + runs=[run], + store=store, + artifact_summaries=CALIBRATION_SUMMARY_PROVIDERS, + ) + result = next( + summary["data"] + for summary in evidence["summaries"] + if summary["artifact"] == "result" + ) + assert result["overview"]["target_count"] == len(registry.specs) + assert result["overview"]["final_weights"]["total"] == float( + manifest.population("uk.full.calibrated").weights_for("household").values.sum() + ) + + def test_national_gates_evaluate_the_seam_scope_and_record_the_evidence( tmp_path, monkeypatch ): diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py new file mode 100644 index 000000000..be743fb7a --- /dev/null +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py @@ -0,0 +1,139 @@ +"""UK supplies grouping and pinned checkpoint references to shared contracts.""" + +import json + +import pytest + +from microcosm.build.uk_runtime.graph import uk_spine_graph +from microcosm.build.uk_runtime.graph_build import UKFullBuildConfig, uk_full_graph +from microcosm.build.uk_runtime.graph_calibration import UKGraphCalibrationConfig +from microcosm.build.uk_runtime.orrery_contract import ( + checkpoint_references, + save_uk_graph_schema, + uk_graph_presentation, +) +from microcosm.graph import ( + Graph, + Node, + Owned, + SourceRef, + StructuralDelta, + orrery_document, +) +from microcosm.graph.evidence import sha256 +from microcosm.graph.orrery import orrery_document_from_schema +from microcosm.graph.presentation import PRESENTATION_EXTENSION + + +def checkpoint_graph(): + return Graph( + "uk", + (SourceRef("uk_spine", "raw-bytes-v1"),), + ( + Node( + "uk.full.spine_checkpoint", + "fixture@1", + sources=("uk_spine",), + structural=StructuralDelta.CREATE, + outputs=(Owned("person", "age", "int64"),), + ), + ), + ) + + +def test_spine_groups_keep_composite_execution_boundaries(): + graph = uk_spine_graph(source_mode="split") + contract = uk_graph_presentation(graph, scope="raw_spine") + document = orrery_document(graph, extensions={PRESENTATION_EXTENSION: contract}) + was = next(node for node in document["nodes"] if node["label"] == "was_wealth") + assert was["data"]["composite"]["execution_unit"] == "composite" + assert "coupled" in was["data"]["composite"]["coupling"] + assert sum(node["kind"] == "operation" for node in document["nodes"]) == len( + graph.nodes + ) + assert not any( + node["kind"] == "operation" and ".fit" in node["label"] + for node in document["nodes"] + ) + + +@pytest.mark.parametrize("households", [None, 100]) +def test_complete_dense_and_size_declarations_fit_existing_export_limits(households): + graph = uk_full_graph( + UKFullBuildConfig( + calibration_year=2025, + geography_assignment="legacy", + calibration=UKGraphCalibrationConfig(dataset_households=households), + ) + ).graph + contract = uk_graph_presentation(graph, scope="full_dense") + document = orrery_document(graph, extensions={PRESENTATION_EXTENSION: contract}) + assert sum(node["kind"] == "operation" for node in document["nodes"]) == len( + graph.nodes + ) + assert len(document["nodes"]) < 20_000 + assert len(document["edges"]) < 100_000 + assert document["metadata"]["presentation_scope"]["id"] == "full_dense" + + +def test_old_checkpoint_has_an_explicit_missing_boundary(tmp_path): + sidecar = tmp_path / "old.build.json" + sidecar.write_text("{}") + contract = uk_graph_presentation( + checkpoint_graph(), scope="checkpoint_dense", sidecar_path=sidecar + ) + boundary = contract["scope"]["boundaries"][0] + assert boundary["upstream"] == [] + assert boundary["missing"] + document = orrery_document( + checkpoint_graph(), extensions={PRESENTATION_EXTENSION: contract} + ) + node = next(node for node in document["nodes"] if node["kind"] == "operation") + assert node["statuses"][0]["label"] == "Upstream evidence missing" + + +def test_checkpoint_links_use_recorded_bytes_and_survive_output_relocation(tmp_path): + sidecar = tmp_path / "spine.build.json" + graph = tmp_path / "original.graph.json" + manifest = tmp_path / "original.manifest.json" + graph.write_bytes(b'{"original":true}') + manifest.write_bytes(b'{"attempt":"recorded"}') + sidecar.write_text( + json.dumps( + { + "graph_declaration": { + "path": str(graph), + "sha256": sha256(graph.read_bytes()), + }, + "graph_manifest": { + "path": str(manifest), + "sha256": sha256(manifest.read_bytes()), + }, + } + ) + ) + saved = save_uk_graph_schema( + checkpoint_graph(), tmp_path / "out", scope="national", sidecar_path=sidecar + ) + relocated = tmp_path / "relocated" + saved.parent.rename(relocated) + saved = relocated / saved.name + document = orrery_document_from_schema(json.loads(saved.read_bytes())) + operation = next(node for node in document["nodes"] if node["kind"] == "operation") + assert len(operation["sources"]) == 2 + for ref in operation["sources"]: + assert sha256((saved.parent / ref["url"]).read_bytes()) == ref["sha256"] + assert ref["url"].startswith("upstream-") + from microcosm.graph.orrery import main + + output = tmp_path / "preview" / "graph.orrery.json" + assert main(["--schema", str(saved), "--output", str(output)]) == 0 + exported = json.loads(output.read_bytes()) + relocated_node = next( + node for node in exported["nodes"] if node["kind"] == "operation" + ) + for ref in relocated_node["sources"]: + assert sha256((output.parent / ref["url"]).read_bytes()) == ref["sha256"] + graph.write_bytes(b'{"original":false}') + with pytest.raises(ValueError, match="digest mismatch"): + checkpoint_references(sidecar) diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_rowwise_national_role.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_rowwise_national_role.py index f7f685013..74c430385 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_rowwise_national_role.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_rowwise_national_role.py @@ -275,6 +275,28 @@ def test_uk_national_role_builds_through_the_graph_and_stages_locally( ): assert path.is_file(), path.name assert not (out / "failure.json").exists() + from microcosm.graph import ( + ContentStore, + collect_execution_evidence, + load_run_evidence, + ) + from microcosm.graph.orrery import orrery_document_from_schema + + schema = json.loads((out / "graph.schema.json").read_bytes()) + inventory = json.loads((out / "operations.json").read_bytes()) + assert "uk.full.national.readback" in {node["id"] for node in inventory["nodes"]} + assert {node["id"] for node in schema["graph"]["nodes"]} == { + node["id"] for node in inventory["nodes"] + } + store = ContentStore(out / ".graph-store", create=False) + runs = load_run_evidence(out / "execution.evidence.json", store=store) + evidence = collect_execution_evidence(schema, runs=runs, store=store) + document = orrery_document_from_schema(schema, execution=evidence) + assert any( + json.loads(node["id"]) == ["operation", "uk.full.national.readback"] + for node in document["nodes"] + ) + assert not list(out.glob("*.orrery.json")) # The build record the release-cut certifier reads: the seam's schema. record = json.loads((out / "build_record.json").read_text()) diff --git a/packages/microcosm-calibrate/src/microcosm/calibrate/graph_evidence.py b/packages/microcosm-calibrate/src/microcosm/calibrate/graph_evidence.py new file mode 100644 index 000000000..c757740e9 --- /dev/null +++ b/packages/microcosm-calibrate/src/microcosm/calibrate/graph_evidence.py @@ -0,0 +1,208 @@ +"""Aggregate graph evidence supplied through shared calibration codecs.""" + +from __future__ import annotations + +import numpy as np + +from microcosm.frame import Frame +from microcosm.graph.evidence import ArtifactSummaryContext + +from .artifacts import ( + PROBLEM_TYPE, + RESULT_TYPE, + SOLUTION_TYPE, + decode_calibration_result, + decode_problem, + decode_solution, +) + + +def weight_summary(weights) -> dict: + values = np.asarray(weights, dtype=np.float64) + if ( + values.ndim != 1 + or not len(values) + or not np.isfinite(values).all() + or (values < 0).any() + ): + raise ValueError("Weight summary requires finite nonnegative weights.") + total = float(values.sum()) + squares = float(np.square(values).sum()) + return { + "records": len(values), + "nonzero": int(np.count_nonzero(values)), + "total": total, + "minimum": float(values.min()), + "maximum": float(values.max()), + "quantiles": dict( + zip( + ("p0", "p25", "p50", "p75", "p90", "p99", "p100"), + np.quantile(values, [0, 0.25, 0.5, 0.75, 0.9, 0.99, 1]).tolist(), + strict=True, + ) + ), + "effective_sample_size": total * total / squares if squares else 0.0, + } + + +def problem_summary(context: ArtifactSummaryContext) -> dict: + ordered = decode_problem(context.payload) + return { + "overview": { + "problem_sha256": ordered.sha256, + "weight_entity": ordered.problem.weight_entity, + "initial_weight_kind": ordered.problem.initial_weights.kind.value, + "targets": len(ordered.problem.targets), + "matrix_shape": list(ordered.problem.matrix.shape), + "matrix_nonzero": int(ordered.problem.matrix.nnz), + "initial_weights": weight_summary(ordered.problem.initial_weights.values), + "skipped_targets": len(ordered.problem.skipped), + } + } + + +def _problem_payload(context: ArtifactSummaryContext) -> bytes: + receipt = context.run.manifest.nodes[context.node_id] + for direction in ("outputs", "inputs"): + for entry in receipt.typed_artifacts.get(direction, {}).values(): + if entry["type"] == { + "name": PROBLEM_TYPE.name, + "schema_version": PROBLEM_TYPE.schema_version, + }: + return context.store.load_bytes(entry["key"]) + raise ValueError("Calibration summary requires a declared ordered problem.") + + +def solution_summary(context: ArtifactSummaryContext) -> dict: + solution = decode_solution(context.payload) + # Installation solutions in an exact-count refit bind the original problem; + # the separately declared refit solution binds the compact problem. + receipt = context.run.manifest.nodes[context.node_id] + problems = [ + decode_problem(context.store.load_bytes(entry["key"])) + for direction in ("outputs", "inputs") + for entry in receipt.typed_artifacts.get(direction, {}).values() + if entry["type"] + == {"name": PROBLEM_TYPE.name, "schema_version": PROBLEM_TYPE.schema_version} + ] + problem = next( + (item for item in problems if item.sha256 == solution.problem_sha256), None + ) + selected = set(solution.entity_ids) + if ( + problem is None + or tuple(identity for identity in problem.entity_ids if identity in selected) + != solution.entity_ids + ): + raise ValueError("Solution summary does not match a declared problem.") + return { + "overview": { + "problem_sha256": solution.problem_sha256, + "weights": weight_summary(solution.weights), + } + } + + +def result_summary_data(result, ordered) -> dict: + """Project recorded diagnostics, never record axes or individual weights.""" + rows = [] + for index, diagnostic in enumerate(result.diagnostics): + target = ordered.problem.targets[index] + metadata = ordered.target_metadata[index] + se = metadata.get("se") + rows.append( + { + "name": diagnostic.name, + "entity": target.entity, + "period": target.period, + "target": diagnostic.target, + "initial": diagnostic.initial_estimate, + "achieved": diagnostic.final_estimate, + "residual": diagnostic.final_estimate - diagnostic.target, + "relative_error": diagnostic.relative_error, + "tolerance": target.tolerance, + "within_tolerance": diagnostic.within_tolerance, + "uncertainty": {"status": "recorded", "se": se} + if isinstance(se, (int, float)) + and not isinstance(se, bool) + and np.isfinite(se) + and se > 0 + else {"status": "not_recorded"}, + "source": target.source, + } + ) + return { + "overview": { + "problem_sha256": ordered.sha256, + "weight_entity": result.weight_entity, + "initial_weight_kind": ordered.problem.initial_weights.kind.value, + "final_weight_kind": result.frame.weights_for( + result.weight_entity + ).kind.value, + "initial_weights": weight_summary(result.initial_weights), + "final_weights": weight_summary(result.weights), + "target_count": len(rows), + "final_loss": result.final_loss, + "l0_lambda": result.l0_lambda, + "record_scope": "Target aggregates and weight distribution; no entity IDs or record values.", + }, + "tables": {"targets": rows}, + } + + +def result_summary(context: ArtifactSummaryContext) -> dict: + ordered = decode_problem(_problem_payload(context)) + version = context.run.compiled.versions[context.node_id] + receipt = context.run.manifest.nodes[version] + if receipt.frame_key is None: + raise ValueError("Calibration summary requires a saved population boundary.") + frame = context.store.load_frame(receipt.frame_key) + entity = ordered.problem.weight_entity + id_column = frame.schema.entity_id_column(entity) + if tuple(frame.table(entity)[id_column]) != ordered.entity_ids: + person = frame.schema.person_entity + membership = ( + id_column if entity == person else frame.schema.membership_column(entity) + ) + frame = frame.select( + frame.table(person)[membership].isin(ordered.entity_ids).to_numpy() + ) + if tuple(frame.table(entity)[id_column]) != ordered.entity_ids: + raise ValueError("Calibration summary differs from its saved population axis.") + initial_frame = Frame( + { + **{e: frame.table(e) for e in frame.entities}, + **{link.name: frame.link(link.name) for link in frame.schema.links}, + }, + frame.schema, + {entity: ordered.problem.initial_weights}, + frame.strata, + ) + result = decode_calibration_result( + context.payload, frame=initial_frame, problem=ordered + ) + outputs = context.run.manifest.nodes[context.node_id].typed_artifacts.get( + "outputs", {} + ) + matching_solutions = [ + decode_solution(context.store.load_bytes(entry["key"])) + for entry in outputs.values() + if entry["type"] + == {"name": SOLUTION_TYPE.name, "schema_version": SOLUTION_TYPE.schema_version} + ] + for solution in matching_solutions: + if solution.problem_sha256 == ordered.sha256: + if solution.entity_ids != ordered.entity_ids or not np.array_equal( + solution.weights, result.weights + ): + raise ValueError( + "Calibration result disagrees with its recorded solution." + ) + return result_summary_data(result, ordered) + + +CALIBRATION_SUMMARY_PROVIDERS = { + (PROBLEM_TYPE.name, PROBLEM_TYPE.schema_version): problem_summary, + (SOLUTION_TYPE.name, SOLUTION_TYPE.schema_version): solution_summary, + (RESULT_TYPE.name, RESULT_TYPE.schema_version): result_summary, +} diff --git a/packages/microcosm-calibrate/tests/engine_free/shared/test_ordered_artifacts.py b/packages/microcosm-calibrate/tests/engine_free/shared/test_ordered_artifacts.py index 2ca56c138..8162d974a 100644 --- a/packages/microcosm-calibrate/tests/engine_free/shared/test_ordered_artifacts.py +++ b/packages/microcosm-calibrate/tests/engine_free/shared/test_ordered_artifacts.py @@ -101,6 +101,7 @@ def test_problem_target_definitions_do_not_densify_the_whole_sparse_system(monke def test_complete_result_rebuild_preserves_dense_and_search_state(monkeypatch): import microcosm.calibrate.solve as solve from microcosm.calibrate import calibrate + from microcosm.calibrate.graph_evidence import result_summary_data frame = Frame( { @@ -113,7 +114,11 @@ def test_complete_result_rebuild_preserves_dense_and_search_state(monkeypatch): {"household": problem().initial_weights}, pd.Series(["a", "a"]), ) - bound = decode_problem(encode_problem(problem(), entity_ids=(10, 20))) + bound = decode_problem( + encode_problem( + problem(), entity_ids=(10, 20), target_metadata=({"se": 2.0}, {}) + ) + ) for penalty in (0.0, 0.01): result = calibrate( frame, @@ -134,6 +139,17 @@ def test_complete_result_rebuild_preserves_dense_and_search_state(monkeypatch): np.testing.assert_array_equal(restored.weights, result.weights) np.testing.assert_array_equal(restored.loss_trajectory, result.loss_trajectory) assert restored.options == result.options + summary = result_summary_data(restored, bound) + rows = summary["tables"]["targets"] + assert rows[0]["uncertainty"] == {"status": "recorded", "se": 2.0} + assert rows[1]["uncertainty"] == {"status": "not_recorded"} + assert [row["achieved"] for row in rows] == [ + diagnostic.final_estimate for diagnostic in restored.diagnostics + ] + assert summary["overview"]["final_weights"]["total"] == pytest.approx( + restored.weights.sum() + ) + assert "entity_ids" not in summary and "weights" not in summary if penalty: np.testing.assert_array_equal( restored.gate_open_probabilities, result.gate_open_probabilities diff --git a/packages/microcosm-graph/src/microcosm/graph/__init__.py b/packages/microcosm-graph/src/microcosm/graph/__init__.py index f266a4d2b..d0db5eb0a 100644 --- a/packages/microcosm-graph/src/microcosm/graph/__init__.py +++ b/packages/microcosm-graph/src/microcosm/graph/__init__.py @@ -164,12 +164,13 @@ def orrery_document( *, title: str | None = None, extensions: Mapping[str, object] | None = None, + execution: object | None = None, ) -> dict[str, object]: """Compile and transform a graph into a complete Orrery document.""" from .orrery import orrery_document as export - return export(graph, title=title, extensions=extensions) + return export(graph, title=title, extensions=extensions, execution=execution) def orrery_json( @@ -177,12 +178,13 @@ def orrery_json( *, title: str | None = None, extensions: Mapping[str, object] | None = None, + execution: object | None = None, ) -> str: """Compile and return deterministic UTF-8-ready Orrery JSON.""" from .orrery import orrery_json as export - return export(graph, title=title, extensions=extensions) + return export(graph, title=title, extensions=extensions, execution=execution) from .codecs import ( # noqa: E402 - check dependency series before runtime import @@ -193,6 +195,14 @@ def orrery_json( load_source, load_source_bytes, ) +from .evidence import ( # noqa: E402 + ArtifactSummaryContext, + RecordedRun, + collect_execution_evidence, + load_run_evidence, + record_run_binding, + save_run_evidence, +) from .executor import NodeRejected, run_graph # noqa: E402 from .explain import explain_html # noqa: E402 from .manifest import Decision, NodeReceipt, PopulationView, RunManifest # noqa: E402 @@ -207,3 +217,12 @@ def orrery_json( StoreUnavailable, ) from .view import describe # noqa: E402 + +__all__ += [ + "ArtifactSummaryContext", + "RecordedRun", + "collect_execution_evidence", + "load_run_evidence", + "record_run_binding", + "save_run_evidence", +] diff --git a/packages/microcosm-graph/src/microcosm/graph/evidence.py b/packages/microcosm-graph/src/microcosm/graph/evidence.py new file mode 100644 index 000000000..bbe60db77 --- /dev/null +++ b/packages/microcosm-graph/src/microcosm/graph/evidence.py @@ -0,0 +1,667 @@ +"""Recorded graph execution, detached from kernels and country runtimes. + +Native bindings retain exact graph/manifest bytes and run-end source identities. +The portable overlay contains only selected operational facts and summaries +from explicitly supplied artifact-type providers. No kernel is called here. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import stat +import uuid +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path + +from .availability import execution_state, unavailable_artifacts +from .canonical import canonical_json, normative +from .decl import ( + GATE_OUTCOMES, + CompiledGraph, + GraphError, + StructuralDelta, + compile_graph, +) +from .kernel import Capabilities, KernelRole +from .keys import ( + artifact_key, + frame_key, + node_key, + opaque_artifact_key, + platform_fingerprint, + seed, + weights_key, +) +from .manifest import RunManifest +from .presentation import digest, require, text +from .schema import _plain_json, _require_compiler_result, validate_graph_schema +from .serialize import graph_from_json, graph_to_json +from .store import ContentStore + +BINDING_PROTOCOL = "microcosm.graph.run-binding.v1" +EVIDENCE_INPUT_PROTOCOL = "microcosm.graph.execution-input.v1" +EXECUTION_PROTOCOL = "microcosm.graph.execution-evidence.v1" +_MAX_BYTES = 32 * 1024 * 1024 + + +def sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _plain(value: object) -> object: + return _plain_json(json.loads(canonical_json(value))) + + +def _unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "duplicate JSON key") + result[key] = value + return result + + +def read_json(path: Path) -> tuple[object, bytes]: + """Bounded regular-file reader used for all native evidence inputs.""" + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)) + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(stream.fileno()) + require(stat.S_ISREG(info.st_mode), "evidence input must be a regular file") + require(info.st_size <= _MAX_BYTES, "evidence input byte limit") + payload = stream.read(_MAX_BYTES + 1) + require(len(payload) <= _MAX_BYTES, "evidence input byte limit") + value = json.loads(payload, object_pairs_hook=_unique) + return _plain_json(value), payload + + +@dataclass(frozen=True) +class RecordedRun: + compiled: CompiledGraph + manifest: RunManifest + binding: Mapping[str, object] + references: Mapping[str, object] = field(default_factory=dict) + summaries: tuple[dict, ...] = () + + +@dataclass(frozen=True) +class ArtifactSummaryContext: + """An explicitly trusted provider's verified artifact context.""" + + run: RecordedRun + node_id: str + artifact: str + payload: bytes + store: ContentStore + + def input_payload(self, alias: str) -> bytes: + entry = self.run.manifest.nodes[self.node_id].typed_artifacts["inputs"][alias] + return self.store.load_bytes(entry["key"]) + + +ArtifactSummaryProvider = Callable[[ArtifactSummaryContext], Mapping[str, object]] +ArtifactSummaryRegistry = Mapping[tuple[str, int], ArtifactSummaryProvider] + + +def record_run_binding( + compiled: CompiledGraph, + manifest: RunManifest, + *, + attempt_id: str, + phase: str, +) -> RecordedRun: + """Capture the executor's live run-end identities before serialization. + + This binds records and bytes, not a signature or an authenticity verdict. + A detached historic manifest cannot manufacture missing source identities. + """ + binding = { + "protocol": BINDING_PROTOCOL, + "attempt_id": text(attempt_id, "attempt id"), + "phase": text(phase, "phase"), + "graph_sha256": sha256(graph_to_json(compiled.graph).encode()), + "manifest_sha256": sha256(manifest.to_json_bytes()), + "manifest_key": manifest.key, + "source_identities": dict(manifest.source_identities), + "platform": platform_fingerprint(), + } + run = RecordedRun(compiled, manifest, binding) + _validate_run(run) + return run + + +def _validate_run(run: RecordedRun) -> None: + compiled, manifest = _require_compiler_result(run.compiled), run.manifest + binding = _plain(dict(run.binding)) + require( + type(binding) is dict + and set(binding) + == { + "protocol", + "attempt_id", + "phase", + "graph_sha256", + "manifest_sha256", + "manifest_key", + "source_identities", + "platform", + }, + "run binding fields", + ) + require(binding["protocol"] == BINDING_PROTOCOL, "run binding protocol") + for field_name in ("attempt_id", "phase", "platform"): + text(binding[field_name], field_name) + require( + manifest.country == compiled.graph.country, "graph/manifest country mismatch" + ) + require( + digest(binding["graph_sha256"]) + == sha256(graph_to_json(compiled.graph).encode()), + "graph binding mismatch", + ) + require(binding["manifest_key"] == manifest.key, "manifest key mismatch") + require( + digest(binding["manifest_sha256"]) == sha256(manifest.to_json_bytes()), + "manifest bytes mismatch", + ) + sources = binding["source_identities"] + require(type(sources) is dict, "source identities must be an object") + require( + set(sources) + == {name for node in compiled.graph.nodes for name in node.sources}, + "missing or unexpected run-end source identities", + ) + for identity in sources.values(): + digest(identity) + require( + set(manifest.nodes) <= set(compiled.order), + "manifest contains unknown operations", + ) + keys = {} + for node_id in compiled.order: + if node_id not in manifest.nodes: + continue + receipt = manifest.nodes[node_id] + require( + not receipt.legacy_capabilities, + "legacy receipt has no current binding contract", + ) + require(isinstance(receipt.capabilities, Capabilities), "receipt capabilities") + require( + receipt.kernel_ref == compiled.graph.node(node_id).kernel, + "kernel reference mismatch", + ) + try: + expected = node_key( + compiled, + node_id, + keys, + receipt.kernel_impl_hash, + sources, + kernel_capabilities=receipt.capabilities, + platform_identity=binding["platform"], + ) + except KeyError as error: + raise ValueError( + f"Graph evidence: incomplete receipt ancestry: {error}" + ) from error + require(receipt.key == expected, f"receipt does not match graph at {node_id!r}") + require(receipt.seed == seed(expected), "receipt seed mismatch") + node = compiled.graph.node(node_id) + require( + receipt.capabilities.structural == node.structural, + "structural contract mismatch", + ) + for (entity, column), key in receipt.artifacts.items(): + require( + key == artifact_key(expected, entity, column), + "column identity mismatch", + ) + if receipt.frame_key is not None: + require(receipt.frame_key == frame_key(expected), "frame identity mismatch") + if receipt.weight_key is not None: + weight_entity = ( + node.weights.entity + if node.weights is not None + else node.params.get("expand_weight_entity") + if node.structural is StructuralDelta.EXPAND + else None + ) + require( + isinstance(weight_entity, str) + and receipt.weight_key == weights_key(expected, weight_entity), + "weight identity mismatch", + ) + for name, key in receipt.opaque_artifacts.items(): + require( + key == opaque_artifact_key(expected, name), + "opaque artifact identity mismatch", + ) + typed = receipt.typed_artifacts + require( + set(typed.get("outputs", {})) + == {item.name for item in node.artifact_outputs}, + "typed output declarations mismatch", + ) + require( + set(typed.get("inputs", {})) + == {item.name for item in node.artifact_inputs}, + "typed input declarations mismatch", + ) + for item in node.artifact_outputs: + entry = typed["outputs"][item.name] + require( + entry["type"] == normative(item.type) + and entry["producer"] == node_id + and entry["key"] == opaque_artifact_key(expected, item.name), + "typed output identity mismatch", + ) + for item in node.artifact_inputs: + entry = typed["inputs"][item.name] + require( + entry["type"] == normative(item.type) + and entry["producer"] == item.producer + and entry["artifact"] == item.artifact + and entry["key"] + == opaque_artifact_key(keys[item.producer], item.artifact), + "typed input identity mismatch", + ) + keys[node_id] = expected + + +def _artifacts(run: RecordedRun, store: ContentStore) -> list[dict]: + """Expose verified byte digests, never payloads, axes or column values.""" + records = {} + for node_id, receipt in run.manifest.nodes.items(): + entries = [("column", f"{e}.{c}", k) for (e, c), k in receipt.artifacts.items()] + entries += [ + ("bytes", name, key) for name, key in receipt.opaque_artifacts.items() + ] + if receipt.frame_key: + entries.append(("frame", "population", receipt.frame_key)) + if receipt.weight_key: + entries.append(("column", "weights", receipt.weight_key)) + for kind, name, key in entries: + if key not in records: + meta = store.metadata(key, kind=kind) + require( + meta.get("node_key") == receipt.key, + f"artifact producer identity mismatch for {key}", + ) + records[key] = { + "key": key, + "kind": kind, + "payloads": _plain(meta["payloads"]), + "producers": [], + } + records[key]["producers"].append({"node": node_id, "name": name}) + return [records[key] for key in sorted(records)] + + +def _summaries( + run: RecordedRun, + store: ContentStore, + registry: ArtifactSummaryRegistry, + *, + cached: Mapping[str, dict] | None = None, +) -> tuple[dict, ...]: + summaries = [] + for node_id, receipt in run.manifest.nodes.items(): + for name, descriptor in receipt.typed_artifacts.get("outputs", {}).items(): + artifact_type = descriptor["type"] + type_key = (artifact_type["name"], artifact_type["schema_version"]) + provider = registry.get(type_key) + if provider is None or name not in receipt.opaque_artifacts: + continue + key = receipt.opaque_artifacts[name] + if cached is not None and key in cached: + summaries.append(cached[key]) + continue + data = _plain( + provider( + ArtifactSummaryContext( + run, node_id, name, store.load_bytes(key), store + ) + ) + ) + require(type(data) is dict, "artifact summary must be an object") + summaries.append( + { + "node": node_id, + "artifact": name, + "key": key, + "type": dict(artifact_type), + "data": data, + } + ) + return tuple(summaries) + + +def collect_execution_evidence( + schema: object, + *, + runs: Sequence[RecordedRun], + store: ContentStore, + artifact_summaries: ArtifactSummaryRegistry | None = None, +) -> dict: + """Validate recorded runs and project a bounded, aggregate-only overlay.""" + schema = validate_graph_schema(schema) + root = compile_graph(graph_from_json(canonical_json(schema["graph"]).decode())) + require(bool(runs), "execution evidence needs a recorded run") + phases = [] + seen = set() + summary_catalog = {} + for run in runs: + _validate_run(run) + require( + run.compiled.graph.country == root.graph.country, "phase country mismatch" + ) + identity = (run.binding["attempt_id"], run.binding["phase"]) + require(identity not in seen, "duplicate attempt/phase") + seen.add(identity) + for node in run.compiled.graph.nodes: + try: + current = root.graph.node(node.id) + except GraphError as error: + raise ValueError( + "Graph evidence: phase operation absent from exported graph." + ) from error + require( + node.normative() == current.normative(), + "phase operation declaration mismatch", + ) + require( + run.compiled.predecessors[node.id] == root.predecessors[node.id], + "phase compiler dependencies mismatch", + ) + require( + run.compiled.graph.normative() == root.graph.normative(), + "phase mass semantics mismatch", + ) + root_sources = {source.name: source for source in root.graph.sources} + for source in run.compiled.graph.sources: + require( + source.name in root_sources + and source.codec == root_sources[source.name].codec, + "phase source declaration mismatch", + ) + artifacts = _artifacts(run, store) + summaries = _summaries(run, store, artifact_summaries or {}) + for summary in run.summaries: + require( + type(summary) is dict + and set(summary) == {"node", "artifact", "key", "type", "data"}, + "recorded summary fields", + ) + text(summary["node"], "summary node") + text(summary["artifact"], "summary artifact") + digest(summary["key"]) + supplied = {(item["node"], item["artifact"]): item for item in run.summaries} + supplied.update({(item["node"], item["artifact"]): item for item in summaries}) + summaries = tuple(supplied.values()) + for summary in summaries: + receipt = run.manifest.nodes.get(summary["node"]) + require(receipt is not None, "summary has no receipt") + descriptor = receipt.typed_artifacts.get("outputs", {}).get( + summary["artifact"] + ) + require( + descriptor is not None + and summary["key"] == receipt.opaque_artifacts.get(summary["artifact"]) + and summary["type"] == dict(descriptor["type"]), + "summary artifact binding mismatch", + ) + require(type(summary["data"]) is dict, "summary data must be an object") + previous = summary_catalog.get(summary["key"]) + require( + previous is None or previous == summary, + "inconsistent summary for one artifact", + ) + summary_catalog[summary["key"]] = summary + operations = {} + for node_id in run.compiled.order: + receipt = run.manifest.nodes.get(node_id) + if receipt is None: + operations[node_id] = { + "execution": "missing", + "cache": "unknown", + "gate": "unknown", + } + continue + state = execution_state(receipt.receipt) + gate = ( + receipt.receipt.get("outcome") + if receipt.capabilities.role is KernelRole.GATE + else "not_applicable" + ) + require(gate in GATE_OUTCOMES, "invalid gate outcome") + operations[node_id] = { + "node_key": receipt.key, + "execution": "unreached" + if state == "unreached" + else "failed" + if state == "gate_exception" + or receipt.receipt.get("rejected") is True + or receipt.receipt.get("outcome") == "rejected" + else "completed", + "cache": "unknown" + if state == "unreached" + else "hit" + if receipt.hit + else "miss", + "gate": gate, + "wall_time_s": receipt.wall_time, + "kernel_ref": receipt.kernel_ref, + "kernel_impl_hash": receipt.kernel_impl_hash, + "blocked_by": dict(receipt.receipt["execution"]["blocked_by"]) + if state == "unreached" + else {}, + "unavailable_artifacts": sorted( + unavailable_artifacts( + receipt.receipt, receipt.typed_artifacts.get("outputs", {}) + ) + ), + "weight_transition": _plain( + normative(run.compiled.graph.node(node_id).weights) + ) + if run.compiled.graph.node(node_id).weights is not None + else None, + "mass": { + key: receipt.receipt["mass"][key] + for key in ("policy", "before", "after") + if key in receipt.receipt.get("mass", {}) + }, + "weight_cap": { + key: receipt.receipt[key] + for key in ( + "weight_anchor", + "max_weight_ratio", + "realized_max_weight_ratio", + ) + if key in receipt.receipt + }, + "artifact_summary_status": { + name: "unavailable" + if name not in receipt.opaque_artifacts + else "present" + if (node_id, name) in supplied + else "provider_not_configured" + for name in receipt.typed_artifacts.get("outputs", {}) + }, + } + phases.append( + { + "attempt_id": identity[0], + "phase": identity[1], + "graph_sha256": run.binding["graph_sha256"], + "manifest_sha256": run.binding["manifest_sha256"], + "manifest_key": run.manifest.key, + "started_at": run.manifest.started_at, + "finished_at": run.manifest.finished_at, + "source_identities": dict(run.binding["source_identities"]), + "operations": operations, + "artifacts": artifacts, + "summaries": [ + {key: item[key] for key in ("node", "artifact", "key")} + for item in summaries + ], + "references": dict(run.references), + } + ) + return _plain( + { + "protocol": EXECUTION_PROTOCOL, + "graph_sha256": schema["graph_sha256"], + "phases": phases, + "summaries": [summary_catalog[key] for key in sorted(summary_catalog)], + "verification": "Recorded graph/receipt keys and store payload digests checked; no kernel replay, signature verification or release certification.", + "history": "Only supplied phases are recorded; absent earlier computation is unknown.", + } + ) + + +def _atomic_json(path: Path, value: object) -> None: + payload = canonical_json(value) + require(len(payload) <= _MAX_BYTES, "native evidence byte limit") + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + temporary.write_bytes(payload) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def save_run_evidence( + compiled: CompiledGraph, + manifest: RunManifest, + *, + store: ContentStore, + directory: Path, + attempt_id: str, + phase: str, + artifact_summaries: ArtifactSummaryRegistry | None = None, +) -> Path: + """Append native evidence for one phase; never create an Orrery snapshot.""" + run = record_run_binding(compiled, manifest, attempt_id=attempt_id, phase=phase) + identity = sha256( + canonical_json([attempt_id, phase, run.binding["manifest_sha256"]]) + ) + directory = Path(directory) + index_path = directory / "execution.evidence.json" + index = ( + read_json(index_path)[0] + if index_path.exists() + else {"protocol": EVIDENCE_INPUT_PROTOCOL, "runs": []} + ) + require(index.get("protocol") == EVIDENCE_INPUT_PROTOCOL, "evidence index protocol") + require( + not any( + (old["attempt_id"], old["phase"]) == (attempt_id, phase) + for old in index["runs"] + ), + "attempt/phase already recorded", + ) + cached = {} + for previous in index["runs"]: + ref = previous["summaries"] + relative = Path(ref["path"]) + require( + not relative.is_absolute() and ".." not in relative.parts, + "summary cache path", + ) + values, payload = read_json(directory / relative) + require( + sha256(payload) == digest(ref["sha256"]), "summary cache digest mismatch" + ) + for item in values: + cached[item["key"]] = item + summary_records = _summaries(run, store, artifact_summaries or {}, cached=cached) + entry = {"attempt_id": attempt_id, "phase": phase} + for name, value in ( + ("graph", json.loads(graph_to_json(compiled.graph))), + ("manifest", json.loads(manifest.to_json())), + ("binding", dict(run.binding)), + ("summaries", list(summary_records)), + ): + relative = Path(identity) / f"{name}.json" + _atomic_json(directory / relative, value) + entry[name] = { + "path": relative.as_posix(), + "sha256": sha256(canonical_json(value)), + } + index["runs"].append(entry) + _atomic_json(index_path, index) + return index_path + + +def load_run_evidence( + path: Path, *, store: ContentStore, reference_base: Path | None = None +) -> tuple[RecordedRun, ...]: + """Load exact, digest-bound phase files without executing country code.""" + path = Path(path) + index, _ = read_json(path) + require( + type(index) is dict and set(index) == {"protocol", "runs"}, + "evidence index fields", + ) + require(index["protocol"] == EVIDENCE_INPUT_PROTOCOL, "evidence index protocol") + require(type(index["runs"]) is list, "evidence runs must be an array") + runs = [] + for entry in index["runs"]: + require( + type(entry) is dict + and set(entry) + == {"attempt_id", "phase", "graph", "manifest", "binding", "summaries"}, + "evidence run fields", + ) + values = {} + refs = {} + for name in ("graph", "manifest", "binding", "summaries"): + ref = entry[name] + require( + type(ref) is dict and set(ref) == {"path", "sha256"}, + "evidence file reference", + ) + relative = Path(text(ref["path"], "evidence path")) + require( + not relative.is_absolute() and ".." not in relative.parts, + "evidence path must stay in bundle", + ) + target = path.parent / relative + require( + target.resolve().is_relative_to(path.parent.resolve()), + "evidence symlink leaves bundle", + ) + values[name], payload = read_json(target) + require( + sha256(payload) == digest(ref["sha256"]), + "evidence file digest mismatch", + ) + refs[name] = { + "url": Path( + os.path.relpath(target, reference_base or path.parent) + ).as_posix(), + "sha256": ref["sha256"], + "label": f"{entry['phase']}: {name}", + } + compiled = compile_graph( + graph_from_json(canonical_json(values["graph"]).decode()) + ) + manifest = RunManifest.load(path.parent / entry["manifest"]["path"], store) + binding = values["binding"] + require( + type(values["summaries"]) is list + and all(type(item) is dict for item in values["summaries"]), + "recorded summaries must be objects in an array", + ) + run = RecordedRun(compiled, manifest, binding, refs, tuple(values["summaries"])) + _validate_run(run) + require( + entry["attempt_id"] == binding["attempt_id"] + and entry["phase"] == binding["phase"], + "phase index/binding mismatch", + ) + runs.append(run) + return tuple(runs) diff --git a/packages/microcosm-graph/src/microcosm/graph/keys.py b/packages/microcosm-graph/src/microcosm/graph/keys.py index e9cc65e2f..4b54dc440 100644 --- a/packages/microcosm-graph/src/microcosm/graph/keys.py +++ b/packages/microcosm-graph/src/microcosm/graph/keys.py @@ -162,6 +162,7 @@ def node_key( source_keys: Mapping[str, str], *, kernel_capabilities: Capabilities, + platform_identity: str | None = None, ) -> str: """Derive a node key from its declaration and resolved input identities. @@ -169,6 +170,10 @@ def node_key( function resolves each declared column exactly as ``compile_graph`` does: a local owner supplies its artifact, while a carried column is supplied by the current structural version's frame. + + ``platform_identity`` is a recorded platform for offline key validation. + Execution omits it and always binds the current platform. Supplying the + current fingerprint produces exactly the existing key. """ node = compiled.graph.node(node_id) @@ -251,7 +256,7 @@ def node_key( # its key carries the platform, so a shared store never serves another # platform's output. Other kernels' keys are unchanged by this. platform_scope = ( - (platform_fingerprint(),) + (platform_fingerprint() if platform_identity is None else platform_identity,) if kernel_capabilities.numeric is Numeric.PLATFORM_BITWISE else () ) diff --git a/packages/microcosm-graph/src/microcosm/graph/orrery.py b/packages/microcosm-graph/src/microcosm/graph/orrery.py index a356c4ad2..bbc40efe9 100644 --- a/packages/microcosm-graph/src/microcosm/graph/orrery.py +++ b/packages/microcosm-graph/src/microcosm/graph/orrery.py @@ -1,7 +1,7 @@ -"""Pure adapter from compiler metadata to Orrery's ``graph-explorer/v1``. +"""Adapter from compiler metadata and recorded evidence to Orrery. -The adapter presents static declarations. It never opens a source, loads a -Frame or kernel, or infers execution, verification, or release status. +Static export never reads population data. The optional execution command +validates saved manifests and store bytes; it never executes a kernel. """ from __future__ import annotations @@ -14,9 +14,13 @@ import stat from collections.abc import Mapping from pathlib import Path +from urllib.parse import urlsplit, urlunsplit from .canonical import canonical_json from .decl import CompiledGraph, Graph, compile_graph +from .errors import GraphRuntimeError +from .orrery_evidence import apply_contracts +from .presentation import presentation_id, validate_presentation from .schema import graph_schema, validate_graph_schema from .serialize import graph_from_json @@ -113,12 +117,10 @@ def _index(values: object, key: str, label: str) -> dict[str, dict[str, object]] return result -def _id(*parts: str) -> str: - return json.dumps(parts, ensure_ascii=False, separators=(",", ":")) - - def _field_id(field: dict[str, object]) -> str: - return _id("field", *(_text(field.get(key), f"field {key}") for key in _FIELD_KEYS)) + return presentation_id( + "field", *(_text(field.get(key), f"field {key}") for key in _FIELD_KEYS) + ) def _revision(value: object) -> str: @@ -156,7 +158,7 @@ def _owned_declarations( def orrery_document_from_schema( - schema: object, *, title: str | None = None + schema: object, *, title: str | None = None, execution: object | None = None ) -> dict[str, object]: """Transform compiler metadata into a complete Orrery document. @@ -204,7 +206,7 @@ def add_edge( data: dict[str, object] | None = None, ) -> None: facts = {} if data is None else data - identity = _id( + identity = presentation_id( "edge", kind, source, target, _bounded_json(facts, _MAX_OUTPUT_BYTES) ) if identity in edges: @@ -223,7 +225,7 @@ def add_edge( for node_id, operation in operations.items(): node: dict[str, object] = { - "id": _id("operation", node_id), + "id": presentation_id("operation", node_id), "label": node_id, "kind": "operation", "data": { @@ -236,7 +238,7 @@ def add_edge( add_node(node) for source_name, source in sources.items(): node = { - "id": _id("source", source_name), + "id": presentation_id("source", source_name), "label": source_name, "kind": "source", "data": {"declaration": source}, @@ -270,7 +272,9 @@ def add_field(field: dict[str, object], *, visible_field: bool) -> str: } ) add_edge( - _id("operation", _text(field.get("provider"), "field provider")), + presentation_id( + "operation", _text(field.get("provider"), "field provider") + ), identity, "provided_field", "provenance", @@ -305,17 +309,17 @@ def add_field(field: dict[str, object], *, visible_field: bool) -> str: field_id = add_field(field, visible_field=False) add_edge( field_id, - _id("operation", _text(binding.get("node"), "binding node")), + presentation_id("operation", _text(binding.get("node"), "binding node")), "declared_read", "dependency", {"read_kind": binding["kind"], "rows": binding["rows"]}, ) for node_id, operation in operations.items(): - target = _id("operation", node_id) + target = presentation_id("operation", node_id) for parent in _array(predecessors[node_id], f"predecessors for {node_id}"): add_edge( - _id("operation", _text(parent, "predecessor")), + presentation_id("operation", _text(parent, "predecessor")), target, "compiled_predecessor", "dependency", @@ -333,7 +337,7 @@ def add_field(field: dict[str, object], *, visible_field: bool) -> str: ) for source in _array(operation.get("sources"), f"sources for {node_id}"): add_edge( - _id("source", _text(source, "source reference")), + presentation_id("source", _text(source, "source reference")), target, "declared_source", "provenance", @@ -343,7 +347,7 @@ def add_field(field: dict[str, object], *, visible_field: bool) -> str: ): binding = _object(value, "artifact input") add_edge( - _id( + presentation_id( "operation", _text(binding.get("producer"), "artifact producer"), ), @@ -363,7 +367,7 @@ def add_field(field: dict[str, object], *, visible_field: bool) -> str: country = _text(document["country"], "country") result: dict[str, object] = { "schemaVersion": "graph-explorer/v1", - "id": _id("microcosm", country), + "id": presentation_id("microcosm", country), "title": _text(title, "title") if title is not None else f"{country.upper()} graph schema", @@ -400,17 +404,26 @@ def add_field(field: dict[str, object], *, visible_field: bool) -> str: ], }, } + apply_contracts(result, document, execution) + _require(len(result["nodes"]) <= _MAX_NODES, "node limit") transported_result = _object(_transport_json(result), "Orrery document") + for node in transported_result["nodes"]: + node["revision"] = _revision( + {key: value for key, value in node.items() if key != "revision"} + ) _bounded_json(transported_result, _MAX_OUTPUT_BYTES) return transported_result -def orrery_json_from_schema(schema: object, *, title: str | None = None) -> str: +def orrery_json_from_schema( + schema: object, *, title: str | None = None, execution: object | None = None +) -> str: """Return deterministic UTF-8-ready JSON for Orrery.""" return ( _bounded_json( - orrery_document_from_schema(schema, title=title), _MAX_OUTPUT_BYTES + orrery_document_from_schema(schema, title=title, execution=execution), + _MAX_OUTPUT_BYTES, ) + "\n" ) @@ -431,11 +444,14 @@ def orrery_document( *, title: str | None = None, extensions: Mapping[str, object] | None = None, + execution: object | None = None, ) -> dict[str, object]: """Compile and transform a graph into a complete Orrery document.""" return orrery_document_from_schema( - graph_schema(_compiled(graph), extensions=extensions), title=title + graph_schema(_compiled(graph), extensions=extensions), + title=title, + execution=execution, ) @@ -444,11 +460,14 @@ def orrery_json( *, title: str | None = None, extensions: Mapping[str, object] | None = None, + execution: object | None = None, ) -> str: """Compile and return deterministic UTF-8-ready Orrery JSON.""" return orrery_json_from_schema( - graph_schema(_compiled(graph), extensions=extensions), title=title + graph_schema(_compiled(graph), extensions=extensions), + title=title, + execution=execution, ) @@ -479,6 +498,33 @@ def _read_json(path: Path) -> object: ) +def _rebase_references(schema: dict, source: Path, output: Path) -> None: + """Keep saved-schema relative links correct beside a relocated export.""" + contract = validate_presentation(schema) + if contract is None: + return + references = [ + ref + for kind in ("operations", "sources") + for record in contract.get(kind, {}).values() + for ref in record.get("references", []) + ] + [ + ref + for boundary in contract.get("scope", {}).get("boundaries", []) + for ref in boundary.get("upstream", []) + ] + for ref in references: + if "url" not in ref: + continue + parsed = urlsplit(ref["url"]) + if not parsed.scheme and not parsed.netloc and not parsed.path.startswith("/"): + relative = Path(os.path.relpath(source.parent / parsed.path, output.parent)) + ref["url"] = urlunsplit( + ("", "", relative.as_posix(), parsed.query, parsed.fragment) + ) + schema["extensions"]["microcosm.presentation"] = contract + + def main(argv: list[str] | None = None) -> int: """Create Orrery JSON from a graph declaration or compiler schema.""" @@ -488,19 +534,45 @@ def main(argv: list[str] | None = None) -> int: inputs.add_argument("--schema", type=Path, help="microcosm.graph.schema.v1 JSON") parser.add_argument("--output", type=Path, required=True, help="new Orrery JSON") parser.add_argument("--title", help="Orrery document title") + parser.add_argument( + "--evidence", type=Path, help="recorded execution-input.v1 index" + ) + parser.add_argument( + "--store", type=Path, help="content store for evidence validation" + ) args = parser.parse_args(argv) + if (args.evidence is None) != (args.store is None): + parser.error("--evidence and --store must be supplied together") try: source = args.graph if args.graph is not None else args.schema value = _read_json(source) if args.graph is not None: graph = graph_from_json(canonical_json(value).decode("utf-8")) - rendered = orrery_json(graph, title=args.title) + schema = graph_schema(_compiled(graph)) else: - rendered = orrery_json_from_schema(value, title=args.title) + schema = validate_graph_schema(value) + _rebase_references(schema, source, args.output) + execution = None + if args.evidence is not None: + from .evidence import collect_execution_evidence, load_run_evidence + from .store import ContentStore + + _require(args.store.is_dir(), "evidence store must exist") + store = ContentStore(args.store, create=False) + execution = collect_execution_evidence( + schema, + runs=load_run_evidence( + args.evidence, store=store, reference_base=args.output.parent + ), + store=store, + ) + rendered = orrery_json_from_schema( + schema, title=args.title, execution=execution + ) args.output.parent.mkdir(parents=True, exist_ok=True) with args.output.open("x", encoding="utf-8", newline="\n") as stream: stream.write(rendered) - except (OSError, TypeError, ValueError, RecursionError) as error: + except (OSError, TypeError, ValueError, RecursionError, GraphRuntimeError) as error: parser.error(str(error)) print(f"wrote {args.output}") return 0 diff --git a/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py b/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py new file mode 100644 index 000000000..7a72fd5a4 --- /dev/null +++ b/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py @@ -0,0 +1,340 @@ +"""Project producer contracts into existing Orrery presentation surfaces.""" + +from __future__ import annotations + +from .canonical import canonical_json +from .evidence import EXECUTION_PROTOCOL, sha256 +from .presentation import ( + digest, + presentation_id, + require, + source_reference, + text, + validate_presentation, +) +from .schema import _plain_json + + +def apply_presentation(document: dict, schema: dict) -> None: + contract = validate_presentation(schema) + if contract is None: + return + nodes = {node["id"]: node for node in document["nodes"]} + membership = {} + for group in contract.get("groups", []): + node = { + "id": presentation_id("group", group["id"]), + "label": group["label"], + "kind": "group", + "data": {"presentation_group": group["id"]}, + } + if "description" in group: + node["description"] = group["description"] + if "parent" in group: + node["parentId"] = presentation_id("group", group["parent"]) + nodes[node["id"]] = node + for plural, kind in (("operations", "operation"), ("sources", "source")): + for member in group.get(plural, []): + identity = presentation_id(kind, member) + membership[identity] = node["id"] + nodes[identity]["parentId"] = node["id"] + for node in nodes.values(): + if node["kind"] == "field": + group = membership.get( + presentation_id("operation", node["data"]["provider"]) + ) + if group is not None: + node["parentId"] = group + for plural, kind in (("operations", "operation"), ("sources", "source")): + for identity, record in contract.get(plural, {}).items(): + node = nodes[presentation_id(kind, identity)] + for prop in ("label", "description"): + if prop in record: + node[prop] = record[prop] + if "composite" in record: + node["data"]["composite"] = record["composite"] + if record.get("references"): + node["sources"] = record["references"] + for boundary in contract.get("scope", {}).get("boundaries", []): + node = nodes[presentation_id("operation", boundary["operation"])] + node["data"]["scope_boundary"] = boundary + node.setdefault("sources", []).extend(boundary.get("upstream", [])) + node.setdefault("statuses", []).append( + { + "label": "Upstream evidence missing" + if boundary.get("missing") + else "Upstream references recorded", + "tone": "warning" if boundary.get("missing") else "neutral", + } + ) + document["nodes"] = [nodes[key] for key in sorted(nodes)] + document["metadata"]["presentation_scope"] = contract.get("scope", {}) + + +def apply_execution(document: dict, schema: dict, execution: object) -> None: + execution = _plain_json(execution) + require(type(execution) is dict, "execution overlay must be an object") + require( + set(execution) + == { + "protocol", + "graph_sha256", + "phases", + "summaries", + "verification", + "history", + } + and execution["protocol"] == EXECUTION_PROTOCOL, + "execution overlay protocol/fields", + ) + require( + digest(execution["graph_sha256"]) == schema["graph_sha256"], + "overlay graph binding mismatch", + ) + require( + type(execution["phases"]) is list and bool(execution["phases"]), + "overlay phases", + ) + text(execution["verification"], "verification scope") + text(execution["history"], "history scope") + nodes = {node["id"]: node for node in document["nodes"]} + operation_ids = {node["id"] for node in schema["graph"]["nodes"]} + source_ids = {source["name"] for source in schema["graph"]["sources"]} + activities = [] + artifacts = {} + seen = set() + latest = {} + summary_catalog = {} + for summary in execution["summaries"]: + key = digest(summary["key"]) + require( + key not in summary_catalog and type(summary["data"]) is dict, + "duplicate or invalid summary", + ) + summary_catalog[key] = summary + for phase in execution["phases"]: + require( + type(phase) is dict + and set(phase) + == { + "attempt_id", + "phase", + "graph_sha256", + "manifest_sha256", + "manifest_key", + "started_at", + "finished_at", + "source_identities", + "operations", + "artifacts", + "summaries", + "references", + }, + "overlay phase fields", + ) + attempt = text(phase["attempt_id"], "attempt id") + name = text(phase["phase"], "phase") + require((attempt, name) not in seen, "duplicate overlay phase") + seen.add((attempt, name)) + for key in ("graph_sha256", "manifest_sha256", "manifest_key"): + digest(phase[key]) + require( + type(phase["operations"]) is dict + and set(phase["operations"]) <= operation_ids, + "overlay operations", + ) + require( + type(phase["source_identities"]) is dict + and set(phase["source_identities"]) <= source_ids, + "overlay sources", + ) + for key in phase["source_identities"].values(): + digest(key) + for source, key in phase["source_identities"].items(): + nodes[presentation_id("source", source)]["data"].setdefault( + "execution_history", [] + ).append({"attempt_id": attempt, "phase": name, "content_identity": key}) + artifact_ids = {} + for artifact in phase["artifacts"]: + key = digest(artifact["key"]) + require(type(artifact["payloads"]) is dict, "artifact payload table") + ids = [] + for filename, payload in artifact["payloads"].items(): + identity = presentation_id("artifact", key, filename) + artifacts[identity] = { + "id": identity, + "label": f"{artifact['producers'][0]['node']}.{artifact['producers'][0]['name']}: {filename}", + "sha256": digest(payload["sha256"]), + } + ids.append(identity) + artifact_ids[key] = ids + require(type(phase["references"]) is dict, "phase references") + refs = [] + phase_artifacts = [] + for kind, ref in phase["references"].items(): + source_reference(ref) + identity = presentation_id("run-artifact", attempt, name, kind) + artifacts[identity] = { + "id": identity, + "label": ref["label"], + "sha256": ref["sha256"], + } + if "url" in ref: + artifacts[identity]["uri"] = ref["url"] + refs.append(ref) + phase_artifacts.append(identity) + summaries = {} + for summary in phase["summaries"]: + require(summary["node"] in phase["operations"], "summary operation missing") + key = digest(summary["key"]) + require(key in artifact_ids, "summary artifact missing") + require(key in summary_catalog, "summary catalog entry missing") + summaries.setdefault(summary["node"], []).append(summary) + for node_id, record in phase["operations"].items(): + require(type(record) is dict, "operation evidence must be an object") + require( + record.get("execution") + in {"completed", "failed", "unreached", "missing"}, + "execution state", + ) + require(record.get("cache") in {"hit", "miss", "unknown"}, "cache state") + require( + record.get("gate") + in { + "pass", + "fail", + "evidence_absent", + "not_applicable", + "unreached", + "unknown", + }, + "gate state", + ) + identity = presentation_id("operation", node_id) + node = nodes[identity] + details = {"attempt_id": attempt, "phase": name, **record} + if node_id in summaries: + details["artifact_summary_keys"] = [ + item["key"] for item in summaries[node_id] + ] + previews = {} + for item in summaries[node_id]: + data = summary_catalog[item["key"]]["data"] + preview = { + key: value for key, value in data.items() if key != "tables" + } + preview["tables"] = { + name: { + "rows": rows[:50], + "total_rows": len(rows), + "preview_limit": 50, + } + for name, rows in data.get("tables", {}).items() + } + previews[item["artifact"]] = {"key": item["key"], **preview} + node["data"]["artifact_summaries"] = previews + node["data"].setdefault("execution_history", []).append(details) + if refs: + node.setdefault("sources", []).extend(refs) + latest[node_id] = record + own_artifacts = [ + identity + for artifact in phase["artifacts"] + if any( + producer["node"] == node_id for producer in artifact["producers"] + ) + for identity in artifact_ids[artifact["key"]] + ] + activity = { + "id": presentation_id("activity", attempt, name, node_id), + "label": f"{name}: {node['label']}", + "kind": "execution", + "outputs": [{"type": "node", "id": identity}], + "inputs": [ + { + "subject": { + "type": "node", + "id": presentation_id("operation", parent), + }, + "role": "provided", + } + for parent in schema["compiled"]["predecessors"][node_id] + ] + + [ + { + "subject": { + "type": "node", + "id": presentation_id("source", source), + }, + "role": "provided", + } + for operation in schema["graph"]["nodes"] + if operation["id"] == node_id + for source in operation["sources"] + ], + "artifactIds": sorted(set(own_artifacts + phase_artifacts)), + "data": { + **details, + "input_scope": "Declared inputs bound into receipt keys; not observed kernel reads.", + "timestamp_scope": "Phase timestamps; operation duration is wall_time_s.", + }, + } + for timestamp, prop in ( + ("started_at", "startedAt"), + ("finished_at", "endedAt"), + ): + if phase[timestamp]: + activity[prop] = phase[timestamp] + activities.append(activity) + for node_id in sorted(operation_ids): + node = nodes[presentation_id("operation", node_id)] + record = latest.get( + node_id, {"execution": "missing", "cache": "unknown", "gate": "unknown"} + ) + statuses = node.setdefault("statuses", []) + statuses.append( + { + "label": f"Execution: {record['execution']}", + "tone": "negative" + if record["execution"] == "failed" + else "warning" + if record["execution"] in {"missing", "unreached"} + else "positive", + } + ) + statuses.append({"label": f"Cache: {record['cache']}", "tone": "neutral"}) + statuses.append( + { + "label": f"Gate: {record['gate']}", + "tone": "positive" + if record["gate"] == "pass" + else "negative" + if record["gate"] == "fail" + else "warning" + if record["gate"] in {"evidence_absent", "unreached", "unknown"} + else "neutral", + } + ) + document["activities"] = activities + document["artifacts"] = [artifacts[key] for key in sorted(artifacts)] + document["metadata"]["execution"] = execution + document["metadata"]["evidence_scope"] = execution["verification"] + document["metadata"]["revision_scope"] = ( + "Content digests of declarations, presentation and supplied execution records; " + "not runtime cache keys." + ) + document["metadata"]["missing_runtime_data"] = [ + "entity identifiers and memberships", + "field values", + "source bytes and preparation internals", + "kernel code, replay and implicit runtime reads", + "raw runtime receipts and signed release decisions", + ] + document["description"] = "Compiler-derived graph with recorded execution evidence." + document["revision"] = "sha256:" + sha256(canonical_json([schema, execution])) + + +def apply_contracts(document: dict, schema: dict, execution: object | None) -> None: + apply_presentation(document, schema) + if execution is not None: + apply_execution(document, schema, execution) diff --git a/packages/microcosm-graph/src/microcosm/graph/presentation.py b/packages/microcosm-graph/src/microcosm/graph/presentation.py new file mode 100644 index 000000000..cc473cbe1 --- /dev/null +++ b/packages/microcosm-graph/src/microcosm/graph/presentation.py @@ -0,0 +1,161 @@ +"""Country-neutral, producer-supplied graph presentation contract.""" + +from __future__ import annotations + +import json +from urllib.parse import urlsplit + +from .schema import _plain_json + +PRESENTATION_EXTENSION = "microcosm.presentation" +PRESENTATION_PROTOCOL = "microcosm.graph.presentation.v1" + + +def presentation_id(*parts: str) -> str: + return json.dumps(parts, ensure_ascii=False, separators=(",", ":")) + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(f"Graph evidence: {message}.") + + +def text(value: object, label: str) -> str: + require(type(value) is str and bool(value.strip()), f"{label} must be text") + return value + + +def digest(value: object) -> str: + require( + type(value) is str + and len(value) == 64 + and all(char in "0123456789abcdef" for char in value), + "expected a lowercase SHA-256 digest", + ) + return value + + +def source_reference(value: object) -> dict: + require(type(value) is dict, "source reference must be an object") + require(set(value) <= {"label", "url", "sha256"}, "unknown source reference field") + text(value.get("label"), "reference label") + if "url" in value: + url = text(value["url"], "reference URL") + parsed = urlsplit(url) + require( + parsed.scheme in {"", "http", "https"} + and (bool(parsed.netloc) if parsed.scheme else not parsed.netloc) + and not url.startswith("//") + and "\\" not in url + and not any(ord(c) < 32 for c in url), + "unsupported reference URL", + ) + if "sha256" in value: + digest(value["sha256"]) + return value + + +def validate_presentation(schema: dict) -> dict | None: + """Validate containment and references without changing compiler facts.""" + value = schema["extensions"].get(PRESENTATION_EXTENSION) + if value is None: + return None + value = _plain_json(value) + require(type(value) is dict, "presentation must be an object") + require(value.get("protocol") == PRESENTATION_PROTOCOL, "presentation protocol") + require( + set(value) <= {"protocol", "scope", "groups", "operations", "sources"}, + "unknown presentation field", + ) + operations = {node["id"] for node in schema["graph"]["nodes"]} + sources = {source["name"] for source in schema["graph"]["sources"]} + groups = value.get("groups", []) + require(type(groups) is list, "groups must be an array") + by_id = {} + assigned = set() + for group in groups: + require(type(group) is dict, "group must be an object") + require( + set(group) + <= {"id", "label", "description", "parent", "operations", "sources"}, + "unknown group field", + ) + identity = text(group.get("id"), "group id") + require(identity not in by_id, "duplicate group id") + text(group.get("label"), "group label") + if "description" in group: + text(group["description"], "group description") + by_id[identity] = group + for kind, available in (("operations", operations), ("sources", sources)): + members = group.get(kind, []) + require(type(members) is list, "group members must be an array") + for member in members: + require( + type(member) is str and member in available, "unknown group member" + ) + coordinate = (kind, member) + require(coordinate not in assigned, "duplicate group membership") + assigned.add(coordinate) + for identity in by_id: + seen = set() + current = identity + while current is not None: + require(current in by_id, "unknown group parent") + require(current not in seen, "cyclic group containment") + seen.add(current) + current = by_id[current].get("parent") + for kind, available in (("operations", operations), ("sources", sources)): + records = value.get(kind, {}) + require(type(records) is dict, "presentation records must be an object") + for identity, record in records.items(): + require(identity in available, "unknown presentation subject") + require(type(record) is dict, "presentation record must be an object") + require( + set(record) <= {"label", "description", "composite", "references"}, + "unknown presentation record field", + ) + for field in ("label", "description"): + if field in record: + text(record[field], field) + refs = record.get("references", []) + require(type(refs) is list, "references must be an array") + for ref in refs: + source_reference(ref) + scope = value.get("scope", {}) + require(type(scope) is dict, "scope must be an object") + require( + set(scope) <= {"id", "label", "description", "boundaries"}, + "unknown scope field", + ) + for field in ("id", "label", "description"): + if field in scope: + text(scope[field], f"scope {field}") + boundaries = scope.get("boundaries", []) + require(type(boundaries) is list, "boundaries must be an array") + seen = set() + for boundary in boundaries: + require(type(boundary) is dict, "boundary must be an object") + require( + set(boundary) <= {"operation", "kind", "upstream", "missing"}, + "unknown boundary field", + ) + node = boundary.get("operation") + require(type(node) is str and node in operations, "unknown boundary operation") + require(node not in seen, "duplicate scope boundary") + seen.add(node) + text(boundary.get("kind"), "boundary kind") + refs = boundary.get("upstream", []) + require(type(refs) is list, "upstream references must be an array") + for ref in refs: + source_reference(ref) + require( + "sha256" in ref and "url" in ref, + "upstream reference needs URL and digest", + ) + require( + bool(refs) or "missing" in boundary, + "boundary needs evidence or a missing reason", + ) + if "missing" in boundary: + text(boundary["missing"], "missing upstream reason") + return value diff --git a/packages/microcosm-graph/src/microcosm/graph/store.py b/packages/microcosm-graph/src/microcosm/graph/store.py index 5af840fd8..4dafc82e8 100644 --- a/packages/microcosm-graph/src/microcosm/graph/store.py +++ b/packages/microcosm-graph/src/microcosm/graph/store.py @@ -687,7 +687,9 @@ def _serialized_series_hash(root: Path, spec: Mapping[str, object]) -> str: class ContentStore: """A filesystem content store rooted at ``root``.""" - def __init__(self, root: Path, *, codecs: object | None = None) -> None: + def __init__( + self, root: Path, *, codecs: object | None = None, create: bool = True + ) -> None: self.root = Path(root) self.objects = self.root / "objects" self.tmp = self.root / "tmp" @@ -697,8 +699,9 @@ def __init__(self, root: Path, *, codecs: object | None = None) -> None: raise TypeError("codecs must be a mapping, codec registry, or None.") self.codecs = codecs self._write_ledgers: list[set[str]] = [] - self.objects.mkdir(parents=True, exist_ok=True) - self.tmp.mkdir(parents=True, exist_ok=True) + if create: + self.objects.mkdir(parents=True, exist_ok=True) + self.tmp.mkdir(parents=True, exist_ok=True) def object_path(self, key: str) -> Path: """Return the canonical directory for ``key`` without reading it.""" diff --git a/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py b/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py new file mode 100644 index 000000000..a408b348b --- /dev/null +++ b/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py @@ -0,0 +1,426 @@ +"""Country-neutral binding, phase history and existing Orrery surfaces.""" + +import ast +import copy +import importlib.util +import json +import subprocess +import sys +from dataclasses import replace + +import pytest + +from microcosm.graph import ( + collect_execution_evidence, + graph_schema, + load_run_evidence, + orrery_document, + orrery_json, + record_run_binding, + save_run_evidence, +) +from microcosm.graph.orrery import main +from microcosm.graph.presentation import PRESENTATION_EXTENSION, PRESENTATION_PROTOCOL +from microcosm.graph.store import StoreCorrupt, StoreMiss +from test_support.paths import paths_for + +_PATHS = paths_for("microcosm-graph") +if "_toy" not in sys.modules: + _SPEC = importlib.util.spec_from_file_location("_toy", _PATHS.tests / "_toy.py") + _TOY = importlib.util.module_from_spec(_SPEC) + sys.modules["_toy"] = _TOY + _SPEC.loader.exec_module(_TOY) +toy = sys.modules["_toy"] + + +def presentation(compiled): + return { + "protocol": PRESENTATION_PROTOCOL, + "scope": {"id": "fixture", "label": "Synthetic country"}, + "groups": [ + {"id": "sources", "label": "Sources", "sources": ["survey"]}, + {"id": "work", "label": "Processing", "operations": list(compiled.order)}, + ], + "operations": {"survey": {"composite": {"execution_unit": "single"}}}, + "sources": { + "survey": { + "references": [ + {"label": "Source definition", "url": "https://example.org/survey"} + ] + } + }, + } + + +@pytest.fixture +def fresh(tmp_path): + return toy.run_toy(toy.full_graph(), tmp_path) + + +def test_fresh_and_cache_phases_keep_same_ids_and_independent_states(fresh, tmp_path): + cached = toy.run_toy( + fresh.compiled.graph, + tmp_path, + sources=fresh.sources, + registry=fresh.registry, + store=fresh.store, + ) + assert fresh.manifest.key == cached.manifest.key + first = record_run_binding( + fresh.compiled, fresh.manifest, attempt_id="attempt", phase="numerical" + ) + second = record_run_binding( + cached.compiled, cached.manifest, attempt_id="attempt", phase="final" + ) + assert first.binding["manifest_sha256"] != second.binding["manifest_sha256"] + assert first.binding["source_identities"] + schema = graph_schema( + fresh.compiled, + extensions={PRESENTATION_EXTENSION: presentation(fresh.compiled)}, + ) + evidence = collect_execution_evidence( + schema, runs=[first, second], store=fresh.store + ) + before = toy.total_calls(fresh.registry) + document = orrery_document( + fresh.compiled, extensions=schema["extensions"], execution=evidence + ) + assert toy.total_calls(fresh.registry) == before + static = orrery_document(fresh.compiled) + assert {node["id"] for node in static["nodes"]} <= { + node["id"] for node in document["nodes"] + } + assert static["edges"] == document["edges"] + operation = next(node for node in document["nodes"] if node["label"] == "survey") + assert operation["parentId"] == '["group","work"]' + history = operation["data"]["execution_history"] + assert [record["cache"] for record in history] == ["miss", "hit"] + assert {record["execution"] for record in history} == {"completed"} + assert len(document["activities"]) == 2 * len(fresh.compiled.order) + assert document["artifacts"] + assert "receipts" not in document + assert document == orrery_document( + fresh.compiled, extensions=schema["extensions"], execution=evidence + ) + assert all(node["revision"] for node in document["nodes"]) + + +def test_gate_failure_is_separate_from_successful_execution(tmp_path): + run = toy.run_toy(toy.full_graph(gate_low=1e20, gate_high=1e21), tmp_path) + binding = record_run_binding( + run.compiled, run.manifest, attempt_id="failed", phase="gates" + ) + evidence = collect_execution_evidence( + graph_schema(run.compiled), runs=[binding], store=run.store + ) + gate = evidence["phases"][0]["operations"]["gate_tax"] + assert gate["gate"] == "fail" + assert gate["execution"] == "completed" + + +def test_changed_implementation_reuses_only_unaffected_nodes(fresh, tmp_path): + changed = toy.run_toy( + fresh.compiled.graph, + tmp_path, + sources=fresh.sources, + store=fresh.store, + registry=toy.toy_registry( + variants={fresh.compiled.graph.node("resources").kernel: "changed"} + ), + ) + overlay = collect_execution_evidence( + graph_schema(fresh.compiled), + runs=[ + record_run_binding( + fresh.compiled, fresh.manifest, attempt_id="first", phase="p" + ), + record_run_binding( + changed.compiled, changed.manifest, attempt_id="second", phase="p" + ), + ], + store=fresh.store, + ) + first, second = (phase["operations"] for phase in overlay["phases"]) + assert second["survey"]["cache"] == "hit" + assert first["survey"]["node_key"] == second["survey"]["node_key"] + assert changed.misses() and changed.hits() + for node_id in changed.misses(): + assert second[node_id]["cache"] == "miss" + assert second[node_id]["execution"] == "completed" + assert first[node_id]["node_key"] != second[node_id]["node_key"] + + +def test_rejects_another_graph_even_with_same_operation_names(fresh): + node = fresh.compiled.graph.node("resources") + changed = toy.replace_node( + fresh.compiled.graph, replace(node, params={**node.params, "scale": 9.0}) + ) + from microcosm.graph import compile_graph + + with pytest.raises(ValueError, match="receipt does not match graph"): + record_run_binding( + compile_graph(changed), fresh.manifest, attempt_id="a", phase="p" + ) + + +def test_detached_manifest_cannot_invent_missing_source_binding(fresh): + from microcosm.graph import RunManifest + + detached = RunManifest.from_json(fresh.manifest.to_json()) + with pytest.raises(ValueError, match="source identities"): + record_run_binding(fresh.compiled, detached, attempt_id="a", phase="p") + + +def test_incomplete_history_is_explicit_and_raw_receipts_are_excluded(fresh): + receipts = dict(fresh.manifest.nodes) + receipts.pop(fresh.compiled.order[-1]) + receipts["survey"] = replace( + receipts["survey"], receipt={"private_household_ids": [123456789]} + ) + partial = replace(fresh.manifest, nodes=receipts) + binding = record_run_binding( + fresh.compiled, partial, attempt_id="a", phase="partial" + ) + evidence = collect_execution_evidence( + graph_schema(fresh.compiled), runs=[binding], store=fresh.store + ) + assert ( + evidence["phases"][0]["operations"][fresh.compiled.order[-1]]["execution"] + == "missing" + ) + assert "absent earlier computation is unknown" in evidence["history"] + assert "private_household_ids" not in json.dumps(evidence) + + +def test_saved_evidence_roundtrip_and_cli(fresh, tmp_path): + directory = tmp_path / "evidence" + index = save_run_evidence( + fresh.compiled, + fresh.manifest, + store=fresh.store, + directory=directory, + attempt_id="a", + phase="p", + ) + assert not list(directory.rglob("*.orrery.json")) + runs = load_run_evidence(index, store=fresh.store) + assert runs[0].binding["source_identities"] == fresh.manifest.source_identities + schema = graph_schema( + fresh.compiled, + extensions={PRESENTATION_EXTENSION: presentation(fresh.compiled)}, + ) + schema_path = tmp_path / "schema.json" + schema_path.write_text(json.dumps(schema)) + output = tmp_path / "orrery.json" + # Read-only export must not recreate a writer's temporary directory. + (fresh.store.root / "tmp").rmdir() + before = toy.total_calls(fresh.registry) + assert ( + main( + [ + "--schema", + str(schema_path), + "--evidence", + str(index), + "--store", + str(fresh.store.root), + "--output", + str(output), + ] + ) + == 0 + ) + assert toy.total_calls(fresh.registry) == before + assert not (fresh.store.root / "tmp").exists() + document = json.loads(output.read_text()) + assert document["activities"] + assert all(len(artifact["sha256"]) == 64 for artifact in document["artifacts"]) + assert next(node for node in document["nodes"] if node["label"] == "survey")[ + "sources" + ] + with pytest.raises(SystemExit): + main( + [ + "--schema", + str(schema_path), + "--evidence", + str(index), + "--output", + str(output), + ] + ) + + +def test_corrupt_evidence_and_store_payloads_fail_closed(fresh, tmp_path): + index = save_run_evidence( + fresh.compiled, + fresh.manifest, + store=fresh.store, + directory=tmp_path / "evidence", + attempt_id="a", + phase="p", + ) + values = json.loads(index.read_text()) + graph_path = index.parent / values["runs"][0]["graph"]["path"] + graph_path.write_bytes(graph_path.read_bytes() + b" ") + with pytest.raises(ValueError, match="file digest mismatch"): + load_run_evidence(index, store=fresh.store) + binding = record_run_binding( + fresh.compiled, fresh.manifest, attempt_id="a", phase="p" + ) + artifact = next(iter(fresh.manifest.nodes["resources"].artifacts.values())) + metadata = fresh.store.metadata(artifact) + payload = fresh.store.object_path(artifact) / next(iter(metadata["payloads"])) + payload.write_bytes(b"corrupt") + with pytest.raises(StoreCorrupt): + collect_execution_evidence( + graph_schema(fresh.compiled), runs=[binding], store=fresh.store + ) + + +def test_offline_evidence_uses_the_recorded_platform(tmp_path, monkeypatch): + import microcosm.graph.evidence as evidence_module + import microcosm.graph.keys as keys_module + from microcosm.graph import Numeric + + registry = toy.toy_registry() + kernel = registry.get("source.csv@1") + kernel.capabilities = replace(kernel.capabilities, numeric=Numeric.PLATFORM_BITWISE) + monkeypatch.setattr( + keys_module, "platform_fingerprint", lambda: "original-platform" + ) + monkeypatch.setattr( + evidence_module, "platform_fingerprint", lambda: "original-platform" + ) + run = toy.run_toy(toy.small_graph(), tmp_path, registry=registry) + index = save_run_evidence( + run.compiled, + run.manifest, + store=run.store, + directory=tmp_path / "evidence", + attempt_id="a", + phase="p", + ) + monkeypatch.setattr(keys_module, "platform_fingerprint", lambda: "viewer-platform") + loaded = load_run_evidence(index, store=run.store) + overlay = collect_execution_evidence( + graph_schema(run.compiled), runs=loaded, store=run.store + ) + assert ( + overlay["phases"][0]["operations"]["survey"]["node_key"] == run.keys()["survey"] + ) + + +def test_artifact_producer_metadata_must_match_the_receipt(fresh): + key = next(iter(fresh.manifest.nodes["resources"].artifacts.values())) + path = fresh.store.object_path(key) / "meta.json" + metadata = json.loads(path.read_bytes()) + metadata["node_key"] = "0" * 64 + path.write_text(json.dumps(metadata)) + binding = record_run_binding( + fresh.compiled, fresh.manifest, attempt_id="a", phase="p" + ) + with pytest.raises(ValueError, match="artifact producer identity mismatch"): + collect_execution_evidence( + graph_schema(fresh.compiled), runs=[binding], store=fresh.store + ) + + +def test_missing_required_store_artifact_is_actionable(fresh, tmp_path, capsys): + index = save_run_evidence( + fresh.compiled, + fresh.manifest, + store=fresh.store, + directory=tmp_path / "evidence", + attempt_id="a", + phase="p", + ) + key = next(iter(fresh.manifest.nodes["resources"].artifacts.values())) + fresh.store.object_path(key).rename(tmp_path / "removed-object") + binding = record_run_binding( + fresh.compiled, fresh.manifest, attempt_id="a", phase="p" + ) + with pytest.raises(StoreMiss, match=key): + collect_execution_evidence( + graph_schema(fresh.compiled), runs=[binding], store=fresh.store + ) + schema_path = tmp_path / "schema.json" + schema_path.write_text(json.dumps(graph_schema(fresh.compiled))) + output = tmp_path / "missing.orrery.json" + with pytest.raises(SystemExit) as error: + main( + [ + "--schema", + str(schema_path), + "--evidence", + str(index), + "--store", + str(fresh.store.root), + "--output", + str(output), + ] + ) + assert error.value.code == 2 + assert key in capsys.readouterr().err + assert not output.exists() + + +def test_shared_contract_modules_have_no_country_dispatch_or_imports(): + directory = _PATHS.package / "src" / "microcosm" / "graph" + for name in ("orrery.py", "evidence.py", "presentation.py", "orrery_evidence.py"): + tree = ast.parse((directory / name).read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + assert "uk" not in (node.module or "").split(".") + assert "uk_runtime" not in (node.module or "").split(".") + elif isinstance(node, ast.Import): + assert all("uk_runtime" not in alias.name for alias in node.names) + elif isinstance(node, ast.Constant) and isinstance(node.value, str): + assert node.value not in {"uk", "us"} + assert not node.value.startswith(("uk.full.", "hmrc_")) + + +@pytest.mark.parametrize( + "change", ["member", "cycle", "duplicate", "unsafe_url", "boundary"] +) +def test_invalid_presentation_contracts_fail(fresh, change): + contract = presentation(fresh.compiled) + if change == "member": + contract["groups"][1]["operations"].append("invented") + elif change == "cycle": + contract["groups"][0]["parent"] = "sources" + elif change == "duplicate": + contract["groups"][0]["operations"] = ["survey"] + elif change == "unsafe_url": + contract["sources"]["survey"]["references"][0]["url"] = "javascript:alert(1)" + else: + contract["scope"]["boundaries"] = [ + {"operation": "survey", "kind": "checkpoint"} + ] + with pytest.raises(ValueError): + orrery_document(fresh.compiled, extensions={PRESENTATION_EXTENSION: contract}) + + +def test_execution_overlay_graph_binding_is_checked(fresh): + run = record_run_binding(fresh.compiled, fresh.manifest, attempt_id="a", phase="p") + evidence = collect_execution_evidence( + graph_schema(fresh.compiled), runs=[run], store=fresh.store + ) + changed = copy.deepcopy(evidence) + changed["graph_sha256"] = "0" * 64 + with pytest.raises(ValueError, match="overlay graph binding mismatch"): + orrery_document(fresh.compiled, execution=changed) + + +def test_public_orrery_parser_accepts_groups_and_execution(fresh): + run = record_run_binding(fresh.compiled, fresh.manifest, attempt_id="a", phase="p") + extension = {PRESENTATION_EXTENSION: presentation(fresh.compiled)} + evidence = collect_execution_evidence( + graph_schema(fresh.compiled), runs=[run], store=fresh.store + ) + payload = orrery_json(fresh.compiled, extensions=extension, execution=evidence) + script = _PATHS.repository / "tools" / "orrery-contract" / "verify-execution.mjs" + result = subprocess.run( + ["node", str(script)], input=payload, text=True, capture_output=True, check=True + ) + assert "accepted" in result.stdout diff --git a/packages/microcosm-graph/tests/engine_free/shared/test_graph_executor.py b/packages/microcosm-graph/tests/engine_free/shared/test_graph_executor.py index e4779c4bb..bc423e36a 100644 --- a/packages/microcosm-graph/tests/engine_free/shared/test_graph_executor.py +++ b/packages/microcosm-graph/tests/engine_free/shared/test_graph_executor.py @@ -3771,6 +3771,27 @@ def test_a_gate_that_declares_evidence_and_raises_leaves_its_consumers_unreached registry = _gate_artifact_registry(raising=True) manifest = _run(graph, source, store, registry) + from microcosm.graph import ( + collect_execution_evidence, + graph_schema, + record_run_binding, + ) + + compiled = compile_graph(graph) + recorded = record_run_binding( + compiled, manifest, attempt_id="gate-error", phase="gate" + ) + overlay = collect_execution_evidence( + graph_schema(compiled), runs=[recorded], store=store + ) + outcomes = overlay["phases"][0]["operations"] + assert outcomes["gate"]["execution"] == "failed" + assert outcomes["gate"]["gate"] == "fail" + assert outcomes["gate"]["unavailable_artifacts"] == ["evidence"] + assert outcomes["use"]["execution"] == "unreached" + assert outcomes["use"]["cache"] == "unknown" + assert outcomes["use"]["blocked_by"] == {"gate": manifest.nodes["gate"].key} + gate = manifest.nodes["gate"] assert gate.receipt["outcome"] == "fail" assert gate.receipt["execution"] == { diff --git a/tools/orrery-contract/verify-execution.mjs b/tools/orrery-contract/verify-execution.mjs new file mode 100644 index 000000000..fd0743bf1 --- /dev/null +++ b/tools/orrery-contract/verify-execution.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { parseGraphDocument } from '@axiom-foundation/orrery'; + +const chunks = []; +for await (const chunk of process.stdin) chunks.push(chunk); +const document = parseGraphDocument(JSON.parse(Buffer.concat(chunks).toString('utf8'))); +assert.ok(document.nodes.some(node => node.kind === 'group')); +assert.ok(document.nodes.some(node => node.parentId)); +assert.ok(document.nodes.some(node => node.sources?.length)); +assert.ok(document.nodes.some(node => node.statuses?.length === 3)); +assert.ok(document.activities.length); +assert.ok(document.artifacts.length); +assert.ok(!document.receipts?.length); +process.stdout.write('Orrery accepted grouping and recorded execution.\n'); From 383fe7600b17b493002b27f67ccab999a4481c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:48:15 +0100 Subject: [PATCH 2/7] Allow recorded gate contracts through graph export bounds --- docs/orrery-adapter.md | 3 +- .../uk/test_uk_staging_integration.py | 20 +++++++++++ .../src/microcosm/graph/orrery.py | 2 +- .../src/microcosm/graph/schema.py | 4 ++- .../engine_free/shared/test_graph_schema.py | 35 +++++++++++++++++++ 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/docs/orrery-adapter.md b/docs/orrery-adapter.md index 7192cc7ac..9a0f466b6 100644 --- a/docs/orrery-adapter.md +++ b/docs/orrery-adapter.md @@ -265,7 +265,8 @@ edges and 18,174,094 JSON bytes; the optional size path has 5,408 nodes, 19,702 edges and 19,588,363 bytes. A synthetic three-household calibration with 22,053 targets produced a 5,346,758-byte execution snapshot retaining every target row. These are declaration and synthetic diagnostic measurements, not a licensed -population run. Existing limits stay unchanged. +population run. Serialized operation contracts can use strings up to 65,536 characters; +the overall document, complexity, node, and edge limits remain unchanged. ## Fields and input bindings diff --git a/packages/microcosm-build/tests/integration/uk/test_uk_staging_integration.py b/packages/microcosm-build/tests/integration/uk/test_uk_staging_integration.py index 499bb96e4..520632176 100644 --- a/packages/microcosm-build/tests/integration/uk/test_uk_staging_integration.py +++ b/packages/microcosm-build/tests/integration/uk/test_uk_staging_integration.py @@ -12,6 +12,8 @@ from microcosm.build.country_spec import load_country_spec from microcosm.build.staging_v2 import validate_v2_bundle +from microcosm.graph import ContentStore, collect_execution_evidence, load_run_evidence +from microcosm.graph.orrery import orrery_document_from_schema from test_support.paths import paths_for _TEST_PATHS = paths_for("microcosm-build") @@ -84,6 +86,24 @@ def test_uk_staging_smoke_command_runs_every_spine_stage(tmp_path: Path) -> None ) assert len(sidecar["synthetic_fixture"]["digest"]) == 64 + schema = json.loads(Path(sidecar["graph_schema"]["path"]).read_bytes()) + store = ContentStore( + tmp_path / ".uk-smoke.checkpoints" / "node-graph", create=False + ) + runs = load_run_evidence( + Path(sidecar["graph_execution_evidence"]["path"]), store=store + ) + document = orrery_document_from_schema( + schema, execution=collect_execution_evidence(schema, runs=runs, store=store) + ) + operations = [node for node in document["nodes"] if node["kind"] == "operation"] + assert len(operations) == len(schema["graph"]["nodes"]) + # These are the full executable gate contracts, not shortened display labels. + assert any( + len(node["data"]["declaration"]["params"].get("gate_manifest", "")) > 16_384 + for node in operations + ) + bundle = validate_v2_bundle(tmp_path / "staging", RUN_ID) manifest = bundle["run_manifest"] assert manifest["status"] == "completed" diff --git a/packages/microcosm-graph/src/microcosm/graph/orrery.py b/packages/microcosm-graph/src/microcosm/graph/orrery.py index bbc40efe9..bac66b6bf 100644 --- a/packages/microcosm-graph/src/microcosm/graph/orrery.py +++ b/packages/microcosm-graph/src/microcosm/graph/orrery.py @@ -60,7 +60,7 @@ def walk(child: object, depth: int) -> object: if child is None or kind is bool: result = child elif kind is str: - _require(len(child) <= 16_384, "string length") + _require(len(child) <= 64 * 1024, "string length") charge += len(child.encode("utf-8")) result = child elif kind is int: diff --git a/packages/microcosm-graph/src/microcosm/graph/schema.py b/packages/microcosm-graph/src/microcosm/graph/schema.py index 9f9d25cfb..7c86d791d 100644 --- a/packages/microcosm-graph/src/microcosm/graph/schema.py +++ b/packages/microcosm-graph/src/microcosm/graph/schema.py @@ -42,7 +42,9 @@ _MAX_ITEMS = 2_000_000 _MAX_BYTES = 32 * 1024 * 1024 _MAX_DEPTH = 64 -_MAX_STRING = 16_384 +# Operation parameters can retain serialized gate contracts larger than 16 KB. +# Keep a scalar bound as well as the total metadata and serialized byte bounds. +_MAX_STRING = 64 * 1024 def _plain_json(value: object) -> object: diff --git a/packages/microcosm-graph/tests/engine_free/shared/test_graph_schema.py b/packages/microcosm-graph/tests/engine_free/shared/test_graph_schema.py index ecb32289d..ad5e8e14d 100644 --- a/packages/microcosm-graph/tests/engine_free/shared/test_graph_schema.py +++ b/packages/microcosm-graph/tests/engine_free/shared/test_graph_schema.py @@ -264,3 +264,38 @@ def test_extensions_are_detached_bounded_json(): invalid["extensions"] = {"bad": float("nan")} with pytest.raises(ValueError, match="finite"): validate_graph_schema(invalid) + + +def test_serialized_operation_contracts_survive_schema_and_orrery_round_trip(): + from microcosm.graph.orrery import orrery_document_from_schema + + compiled = compiled_graph() + # Maintained gate declarations contain serialized contracts around 38 KB. + contract = "x" * 40_000 + original = compiled.graph.nodes[0] + graph = replace( + compiled.graph, + nodes=( + replace(original, params={**original.params, "contract": contract}), + *compiled.graph.nodes[1:], + ), + ) + schema = graph_schema(compile_graph(graph)) + assert validate_graph_schema(schema) == schema + document = orrery_document_from_schema(schema) + operation = next( + node + for node in document["nodes"] + if node["kind"] == "operation" and node["label"] == original.id + ) + assert operation["data"]["declaration"]["params"]["contract"] == contract + + oversized = replace( + graph, + nodes=( + replace(original, params={"contract": "x" * (64 * 1024 + 1)}), + *graph.nodes[1:], + ), + ) + with pytest.raises(ValueError, match="length limit"): + graph_schema(compile_graph(oversized)) From e0ba98a08d19d99b3417e7b4690d067e6872bc7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:37:35 +0100 Subject: [PATCH 3/7] Support full UK gate contracts in graph exports --- .../1144-uk-full-graph-export.fixed.md | 1 + docs/orrery-adapter.md | 13 +++++--- .../engine_free/uk/test_uk_orrery_contract.py | 30 ++++++++++++++++--- .../src/microcosm/graph/orrery.py | 2 +- .../src/microcosm/graph/schema.py | 4 +-- .../engine_free/shared/test_graph_schema.py | 6 ++-- 6 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 changelog.d/1144-uk-full-graph-export.fixed.md diff --git a/changelog.d/1144-uk-full-graph-export.fixed.md b/changelog.d/1144-uk-full-graph-export.fixed.md new file mode 100644 index 000000000..a5a7ca105 --- /dev/null +++ b/changelog.d/1144-uk-full-graph-export.fixed.md @@ -0,0 +1 @@ +Allow the maintained full UK gate contracts to round-trip through compiler schema and Orrery export, while retaining bounded strings and document sizes. diff --git a/docs/orrery-adapter.md b/docs/orrery-adapter.md index 9a0f466b6..31f2967c4 100644 --- a/docs/orrery-adapter.md +++ b/docs/orrery-adapter.md @@ -259,14 +259,19 @@ the artifact labels include their producing operation and output name so they remain identifiable. Browsing a synthetic saved national run confirmed groups, Record fields, separate status badges, Sources links and phase activities. -Measured with the maintained full UK declaration, calibration year 2025 and -legacy geography assignment: dense export has 5,148 presentation nodes, 18,135 +Measured with the maintained UK numerical declaration, calibration year 2025 and +legacy geography assignment, before appending terminal gates: dense export has 5,148 presentation nodes, 18,135 edges and 18,174,094 JSON bytes; the optional size path has 5,408 nodes, 19,702 edges and 19,588,363 bytes. A synthetic three-household calibration with 22,053 targets produced a 5,346,758-byte execution snapshot retaining every target row. These are declaration and synthetic diagnostic measurements, not a licensed -population run. Serialized operation contracts can use strings up to 65,536 characters; -the overall document, complexity, node, and edge limits remain unchanged. +population run. + +Including the maintained spine and full gate batteries, holdout and export +preparation produces 77 operations, 5,156 presentation nodes, 19,593 edges and +19,914,678 JSON bytes. The full gate contract contains 65,971 characters. +Serialized operation contracts can use strings up to 131,072 characters; the +overall document, complexity, node, and edge limits remain unchanged. ## Fields and input bindings diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py index be743fb7a..e24a25b08 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py @@ -4,9 +4,12 @@ import pytest -from microcosm.build.uk_runtime.graph import uk_spine_graph +from microcosm.build.country_spec import load_country_spec +from microcosm.build.uk_runtime.graph import uk_spine_endpoint, uk_spine_graph from microcosm.build.uk_runtime.graph_build import UKFullBuildConfig, uk_full_graph from microcosm.build.uk_runtime.graph_calibration import UKGraphCalibrationConfig +from microcosm.build.uk_runtime.graph_evidence import add_uk_spine_gate_nodes +from microcosm.build.uk_runtime.graph_terminal import append_uk_full_gate_nodes from microcosm.build.uk_runtime.orrery_contract import ( checkpoint_references, save_uk_graph_schema, @@ -59,13 +62,26 @@ def test_spine_groups_keep_composite_execution_boundaries(): @pytest.mark.parametrize("households", [None, 100]) def test_complete_dense_and_size_declarations_fit_existing_export_limits(households): - graph = uk_full_graph( + spine = add_uk_spine_gate_nodes( + uk_spine_graph(source_mode="split"), + spec=load_country_spec("uk"), + engine_identity="declaration-only", + ) + full = uk_full_graph( UKFullBuildConfig( calibration_year=2025, geography_assignment="legacy", calibration=UKGraphCalibrationConfig(dataset_households=households), - ) - ).graph + ), + spine=spine, + ) + graph = append_uk_full_gate_nodes( + full.graph, + calibration=full.calibration, + spine_stage_names=uk_spine_endpoint(spine).stage_names, + engine_identity="declaration-only", + review_date="2026-10-08", + ) contract = uk_graph_presentation(graph, scope="full_dense") document = orrery_document(graph, extensions={PRESENTATION_EXTENSION: contract}) assert sum(node["kind"] == "operation" for node in document["nodes"]) == len( @@ -74,6 +90,12 @@ def test_complete_dense_and_size_declarations_fit_existing_export_limits(househo assert len(document["nodes"]) < 20_000 assert len(document["edges"]) < 100_000 assert document["metadata"]["presentation_scope"]["id"] == "full_dense" + for node_id in ("uk.full.gates.preflight", "uk.full.gates.calibrated"): + operation = next(node for node in document["nodes"] if node["label"] == node_id) + assert ( + operation["data"]["declaration"]["params"]["gate_manifest"] + == (graph.node(node_id).params["gate_manifest"]) + ) def test_old_checkpoint_has_an_explicit_missing_boundary(tmp_path): diff --git a/packages/microcosm-graph/src/microcosm/graph/orrery.py b/packages/microcosm-graph/src/microcosm/graph/orrery.py index bac66b6bf..aae8e5b27 100644 --- a/packages/microcosm-graph/src/microcosm/graph/orrery.py +++ b/packages/microcosm-graph/src/microcosm/graph/orrery.py @@ -60,7 +60,7 @@ def walk(child: object, depth: int) -> object: if child is None or kind is bool: result = child elif kind is str: - _require(len(child) <= 64 * 1024, "string length") + _require(len(child) <= 128 * 1024, "string length") charge += len(child.encode("utf-8")) result = child elif kind is int: diff --git a/packages/microcosm-graph/src/microcosm/graph/schema.py b/packages/microcosm-graph/src/microcosm/graph/schema.py index 7c86d791d..c71004dff 100644 --- a/packages/microcosm-graph/src/microcosm/graph/schema.py +++ b/packages/microcosm-graph/src/microcosm/graph/schema.py @@ -42,9 +42,9 @@ _MAX_ITEMS = 2_000_000 _MAX_BYTES = 32 * 1024 * 1024 _MAX_DEPTH = 64 -# Operation parameters can retain serialized gate contracts larger than 16 KB. +# Operation parameters can retain serialized gate contracts larger than 64 KiB. # Keep a scalar bound as well as the total metadata and serialized byte bounds. -_MAX_STRING = 64 * 1024 +_MAX_STRING = 128 * 1024 def _plain_json(value: object) -> object: diff --git a/packages/microcosm-graph/tests/engine_free/shared/test_graph_schema.py b/packages/microcosm-graph/tests/engine_free/shared/test_graph_schema.py index ad5e8e14d..ed1a9d171 100644 --- a/packages/microcosm-graph/tests/engine_free/shared/test_graph_schema.py +++ b/packages/microcosm-graph/tests/engine_free/shared/test_graph_schema.py @@ -270,8 +270,8 @@ def test_serialized_operation_contracts_survive_schema_and_orrery_round_trip(): from microcosm.graph.orrery import orrery_document_from_schema compiled = compiled_graph() - # Maintained gate declarations contain serialized contracts around 38 KB. - contract = "x" * 40_000 + # Full UK gate declarations exceed 64 KiB; spine-only gates are smaller. + contract = "x" * 70_000 original = compiled.graph.nodes[0] graph = replace( compiled.graph, @@ -293,7 +293,7 @@ def test_serialized_operation_contracts_survive_schema_and_orrery_round_trip(): oversized = replace( graph, nodes=( - replace(original, params={"contract": "x" * (64 * 1024 + 1)}), + replace(original, params={"contract": "x" * (128 * 1024 + 1)}), *graph.nodes[1:], ), ) From 2b5149b4431a113decc1380f6f42f9b47a778110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:53:50 +0100 Subject: [PATCH 4/7] Shape Orrery exports for the viewer and share the driver helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against Orrery 0.6.1 with the maintained UK dense declaration (76 operations, 5,355 presentation nodes) and recorded synthetic runs, the first export landed badly in the viewer: cards hid gate verdicts behind execution and cache badges, every replay phase relabelled computed operations as cache hits, each column file became its own artifact reference, the compiler schema was embedded twice, and the UK substring grouping put 65 of 76 operations in "enrichment". Shared exporter - Contain each field node in the operation that provides it, so folding an operation or group folds its versioned fields; describe fields by population version and provider; label declared reads. - Lead gate kernels with the gate badge and omit it elsewhere; summarise the cache state across every supplied phase ("Computed: numerical · reused 2×", "Reused: 3 cache hits; computation not in supplied phases") instead of reporting the last replay. - Emit one artifact reference per content-store object with its byte digest when it is a single file; keep per-file digests in the phase record; attach native run files to activities only. - Name the executing kernel as each activity's agent and record the kernel role per phase. - Put the presentation scope into the document id and default title. - Replace the embedded compiler schema with a digest-bound summary. - Add shared helpers: save_graph_schema (copies and relinks checkpoint bytes), checkpoint_references (links only verifiable files, names absent ones with their digest), publish_run_evidence (flat publication merged across attempts, refusing files that no longer verify). UK contracts - Replace substring grouping with an explicit roster and group descriptions; unrostered operations land in "other" and the maintained declarations are tested to have none. - Summarise spine, full and national gate reports per gate through the summary registry, and record the spine's execution evidence index as a checkpoint reference. - Wire the spine driver to the UK summary providers and the drivers to the shared helpers. The contract check asserts badge policy, containment, agents and aggregated artifacts rather than a badge count. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1080.added.md | 2 +- docs/orrery-adapter.md | 127 ++++-- .../build/uk_runtime/orrery_contract.py | 382 ++++++++++++------ .../microcosm/build/uk_runtime/spine_build.py | 6 +- .../uk/test_uk_full_calibration_graph.py | 29 ++ .../engine_free/uk/test_uk_orrery_contract.py | 109 +++++ .../src/microcosm/graph/__init__.py | 6 + .../src/microcosm/graph/evidence.py | 199 ++++++++- .../src/microcosm/graph/orrery.py | 86 +++- .../src/microcosm/graph/orrery_evidence.py | 162 +++++--- .../engine_free/shared/test_graph_evidence.py | 204 +++++++++- .../engine_free/shared/test_graph_orrery.py | 64 ++- tools/orrery-contract/verify-execution.mjs | 14 +- 13 files changed, 1155 insertions(+), 235 deletions(-) diff --git a/changelog.d/1080.added.md b/changelog.d/1080.added.md index 24812b4f8..f6e1e1077 100644 --- a/changelog.d/1080.added.md +++ b/changelog.d/1080.added.md @@ -1 +1 @@ -Add country-neutral recorded execution exports for Orrery, with independent execution/cache/gate states, phase history, digest-bound evidence links and aggregate calibration diagnostics. UK drivers preserve native evidence and presentation contracts, including checkpoint boundaries and the final national readback inventory. Orrery snapshots remain opt-in. +Add country-neutral recorded execution exports for Orrery: gate-first independent status badges with an attempt-level cache summary, phase history, one artifact reference per content-store object, the executing kernel as each activity's agent, digest-bound evidence links and aggregate calibration, size-search and per-gate diagnostics. Fields are contained by their providing operation, scope names the document, and `metadata.microcosm` binds the separately saved compiler schema by digest. Shared helpers save the schema with copied checkpoint bytes, project checkpoint provenance without linking absent files, and publish flat evidence merged across attempts. UK drivers preserve native evidence through an explicit presentation roster. Orrery snapshots remain opt-in. diff --git a/docs/orrery-adapter.md b/docs/orrery-adapter.md index 31f2967c4..1385b7bda 100644 --- a/docs/orrery-adapter.md +++ b/docs/orrery-adapter.md @@ -128,24 +128,39 @@ schema = graph_schema( ``` Group members name real operations or sources. Optional `parent` names another -group. Groups use Orrery containment and preserve every dependency edge. Field -nodes inherit their provider's group. The validator rejects unknown members, -duplicate membership and containment cycles. Operation/source records can -provide `label`, `description`, `composite` metadata and `references`, each with -a `label` and optional HTTP(S)/relative `url` and `sha256`. +group. Groups use Orrery containment and preserve every dependency edge. A field +node is contained by the operation whose artifact provides it, so folding an +operation folds its versioned fields and folding a group folds both. The +validator rejects unknown members, duplicate membership and containment cycles. +Operation/source records can provide `label`, `description`, `composite` +metadata and `references`, each with a `label` and optional HTTP(S)/relative +`url` and `sha256`. When the scope has an `id`, the Orrery document id becomes +`["microcosm", country, scope id]` and the default title uses the scope label, +so spine, dense and national snapshots of one country stay distinguishable in a +host's revision picker and in baseline comparisons. Scope boundaries name an `operation` and `kind`, with `upstream` references requiring both URL and digest, or a `missing` reason. UK inputs identify raw -spine, full dense, checkpoint dense and national scopes. Composite descriptions -come from the maintained UK operation inventory; a coupled fit/draw stays one -operation and one cache boundary. - -The UK spine driver saves its producing declaration, compiler schema, run -manifest and execution binding. Its checkpoint sidecar records immutable -per-attempt graph/manifest links and byte digests. Downstream UK schemas copy -these recorded bytes into `upstream-.json` files and use relative links. -Old checkpoints expose missing provenance explicitly. The exporter never -reconstructs an earlier spine from current source code or matching field names. +spine, full dense, checkpoint dense and national scopes. UK groups are an +explicit roster (`uk_runtime.orrery_contract.UK_GROUPS`): sources, enrichment +(spine stages with their ownership helpers), population pool, geography, +targets, calibration, checks and export. An operation that is not rostered +lands in an "other" group, and the maintained declarations are tested to have +none. Composite descriptions come from the maintained UK operation inventory; a +coupled fit/draw stays one operation and one cache boundary. + +Checkpoint provenance is a small shared record. A producer writes +`graph_declaration`, `graph_manifest` and optionally `graph_schema` and +`graph_execution_evidence` entries, each with `path` and `sha256`, into its +checkpoint sidecar; `microcosm.graph.checkpoint_references` projects them into +upstream references. Only files whose bytes still match their digest are +linked; an absent file is named in the `missing` reason together with its +recorded digest, never linked by a local path. The UK spine driver writes all +four entries. `microcosm.graph.save_graph_schema` copies linked local bytes +into `upstream-.json` beside `graph.schema.json` and relinks them +relatively, so a published build and a later HTML export travel together. The +exporter never reconstructs an earlier spine from current source code or +matching field names. ## Recorded execution @@ -175,7 +190,13 @@ record-level values. `save_run_evidence` appends an `execution.evidence.json` index with protocol `microcosm.graph.execution-input.v1`, plus exact graph, manifest, binding and -summary files. `microcosm.graph.run-binding.v1` records the graph/manifest byte +summary files. `publish_run_evidence(index, out_dir)` flattens that durable +attempt bundle into one publisher directory as `evidence---.json` and merges the index with attempts published there +earlier, after re-checking every published file's digest. A repeated or resumed +build into the same output directory therefore keeps the record of the attempt +that computed a result next to the replay that reused it; a published file +that no longer verifies refuses publication instead of being dropped. `microcosm.graph.run-binding.v1` records the graph/manifest byte digests, manifest key, attempt, phase, platform and identities of used sources. These native files support later export; they do not create an Orrery snapshot. A serialized historical manifest without this binding cannot supply missing @@ -212,6 +233,12 @@ npx orrery --input build/graph.orrery.json --output build/graph.orrery.html `--evidence` and `--store` must appear together. Native evidence links and saved-schema relative references are rebased to the command's output directory; keep the referenced files available with the HTML. + +Other countries supply the same three inputs: a presentation dictionary (groups, +scope, optional labels and references), an artifact-summary registry keyed by +artifact type, and driver wiring that calls `save_graph_schema`, +`save_run_evidence`/`publish_run_evidence` and writes the checkpoint provenance +record. No shared module changes are needed. UK full/national drivers save `graph.schema.json`, `execution.evidence.json` and flat `evidence-*.json` files beside `graph.json` and `operations.json`. Durable copies also remain in the @@ -236,10 +263,29 @@ explicit. Earlier phases not supplied to the exporter remain unknown. | Surface | Exported evidence | | --- | --- | -| Record | Operation declarations, attempt/phase history, duration, numerical keys, mass and weight transitions, calibration/size aggregates | -| Sources | Digest-bound native graph/manifest/binding/summary links and recorded checkpoint references | -| Activity | One record per operation and supplied phase, declared operation/source inputs, artifact references and phase timestamps | -| Status badges | Independent execution, cache and gate states, including missing/unreached and evidence-absent outcomes | +| Record | Operation declarations, attempt/phase history with kernel role, duration, numerical keys, mass and weight transitions, artifact keys, calibration/size aggregates and per-gate outcomes | +| Sources | Presentation references and recorded checkpoint references | +| Activity | One record per operation and supplied phase; the kernel (`name@version`, implementation hash) as the agent; declared operation/source inputs; artifact references including the digest-bound native graph/manifest/binding/summary files; phase timestamps | +| Status badges | Gate verdict first on gate kernels only, then execution state, then an attempt-level cache summary | + +Orrery cards show the first two badges. A gate kernel therefore leads with +`Gate: pass`/`fail`/`evidence_absent`/`unreached`; other operations carry no +gate badge. The cache badge summarises every supplied phase: `Computed: +numerical · reused 2×` names the phase that computed the result before counting +replays, `Reused: 3 cache hits; computation not in supplied phases` is explicit +when no supplied phase computed it, and `Cache: unknown` means no receipt. + +Artifact references are one per content-store object (frame, column, weights, +typed or opaque artifact), labelled by producing operation, output name, kind +and file count. A single-file object carries its byte digest; the per-file +payload digests of every object are in `metadata.execution.phases[].artifacts`. +This keeps a structural operation's activity to tens of references rather than +one row per column file. + +UK gate batteries (spine, full and national gate reports) are summarised per +gate: identifier, phase, criticality, status, failure lines and reason, with +enforcement or blocking verdicts in the overview. Gate detail payloads and +target or area tables stay in the artifact. Per-target tables contain target, initial and achieved values, residuals, relative error, tolerance and recorded uncertainty where available. Full tables @@ -259,19 +305,27 @@ the artifact labels include their producing operation and output name so they remain identifiable. Browsing a synthetic saved national run confirmed groups, Record fields, separate status badges, Sources links and phase activities. -Measured with the maintained UK numerical declaration, calibration year 2025 and -legacy geography assignment, before appending terminal gates: dense export has 5,148 presentation nodes, 18,135 -edges and 18,174,094 JSON bytes; the optional size path has 5,408 nodes, 19,702 -edges and 19,588,363 bytes. A synthetic three-household calibration with 22,053 -targets produced a 5,346,758-byte execution snapshot retaining every target row. -These are declaration and synthetic diagnostic measurements, not a licensed -population run. +## Large snapshots and deep links + +Field nodes are the bulk of a UK snapshot: the maintained dense declaration has +76 operations, 15 sources and about 5,300 field nodes with 20,000 +relationships. Orrery lays out every visible node, so the whole-graph view of +such a snapshot takes a minute to appear and is unreadable until filtered. The +offline HTML honours Orrery's URL hash, so publish or open it with a location +that starts from the operations: + +```text +graph.orrery.html#kinds=["operation","source","group"] +graph.orrery.html#kinds=["operation","source","group"]&focusId=["operation","uk.full.dense"]&depth=1 +graph.orrery.html#collapsedIds=["[\"group\",\"enrichment\"]"] +``` -Including the maintained spine and full gate batteries, holdout and export -preparation produces 77 operations, 5,156 presentation nodes, 19,593 edges and -19,914,678 JSON bytes. The full gate contract contains 65,971 characters. -Serialized operation contracts can use strings up to 131,072 characters; the -overall document, complexity, node, and edge limits remain unchanged. +Fields remain searchable and inspectable from the index, and expanding one +operation shows exactly its versioned fields because fields are contained by +their provider. Serialized operation contracts can use strings up to 131,072 +characters; the overall document, complexity, node, and edge limits remain +unchanged. These are declaration and synthetic diagnostic measurements, not a +licensed population run. ## Fields and input bindings @@ -320,8 +374,13 @@ receipts. The adapter creates operation, source, and versioned-field nodes. Descriptions are promoted to Orrery's visible `description` property while the complete -declaration remains in `data`. The entire compiler schema remains available at -`metadata.microcosm`. +declaration remains in `data`. `metadata.microcosm` is a bounded summary of the +compiler schema (protocol, country, graph and canonical schema digests, +compiled order, record counts and the producer extensions); the complete schema +is the separately saved `graph.schema.json` whose bytes have +`metadata.microcosm.schema_sha256`. Orrery renders snapshot metadata as text, +so the viewer document does not repeat declarations that its records already +carry. | Relationship | Category | Meaning | | --- | --- | --- | diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_contract.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_contract.py index 5f86eb775..66787056d 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_contract.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/orrery_contract.py @@ -7,20 +7,28 @@ import numpy as np +from microcosm.build.country_spec import CountrySpec, load_country_spec from microcosm.calibrate.artifacts import RESULT_TYPE, decode_problem from microcosm.calibrate.graph_evidence import ( CALIBRATION_SUMMARY_PROVIDERS, result_summary, ) -from microcosm.graph import compile_graph, graph_schema, save_run_evidence -from microcosm.graph.canonical import canonical_json -from microcosm.graph.evidence import ArtifactSummaryContext, read_json, sha256 -from microcosm.graph.presentation import PRESENTATION_EXTENSION, PRESENTATION_PROTOCOL +from microcosm.graph import ( + compile_graph, + publish_run_evidence, + save_graph_schema, + save_run_evidence, +) +from microcosm.graph.evidence import ArtifactSummaryContext, read_json +from microcosm.graph.evidence import checkpoint_references as _checkpoint_references +from microcosm.graph.presentation import PRESENTATION_PROTOCOL -from .graph import uk_spine_operation_inventory +from .graph import _manifest_stages, uk_spine_operation_inventory from .graph_calibration import SIZE_DRAW_TYPE, SIZE_RECEIPT_TYPE, SIZE_SEARCH_TYPE -from .graph_national import NATIONAL_TARGET_TYPE +from .graph_evidence import SPINE_GATE_REPORT_TYPE +from .graph_national import NATIONAL_GATE_REPORT_TYPE, NATIONAL_TARGET_TYPE from .graph_targets import TARGET_SELECTION_TYPE, registry_from_payload +from .graph_terminal import FULL_GATE_REPORT_TYPE def _uk_result_summary(context: ArtifactSummaryContext) -> dict: @@ -142,12 +150,116 @@ def _size_summary(context: ArtifactSummaryContext) -> dict: return {"overview": summary} +_GATE_STATUSES = ("passed", "failed", "not_applicable", "evidence_absent", "unreached") +_GATE_ROW_FIELDS = ("id", "gate", "phase", "criticality", "status", "reason") +_FULL_ENFORCEMENT_FIELDS = ( + "structural_failures", + "enforced_blocking", + "exportable_blocking", + "unenforced_release_failures", + "diagnostic_failures", + "artifact_permitted", + "release_blocking_gates_passed", +) + + +def _gate_rows(report: object) -> tuple[list[dict], dict]: + """Project one phase report's per-gate verdicts; details stay in the artifact.""" + if not isinstance(report, dict) or report.get("schema_version") != 1: + raise ValueError("Unsupported gate phase report.") + outcomes = report.get("outcomes") + if not isinstance(outcomes, list): + raise ValueError("Gate phase report has no outcome list.") + rows = [] + counts = dict.fromkeys(_GATE_STATUSES, 0) + for outcome in outcomes: + if not isinstance(outcome, dict) or outcome.get("status") not in counts: + raise ValueError("Gate outcome has an unknown status.") + row = {field: outcome.get(field) for field in _GATE_ROW_FIELDS} + if not isinstance(row["id"], str) or not row["id"]: + raise ValueError("Gate outcome has no identifier.") + failures = outcome.get("failures", []) + if not isinstance(failures, list) or not all( + isinstance(line, str) for line in failures + ): + raise ValueError("Gate failures must be text lines.") + row["failures"] = failures + row["evidence_sha256"] = outcome.get("evidence_sha256") + counts[row["status"]] += 1 + rows.append(row) + overview = { + "phase": report.get("phase"), + "gates_manifest_sha256": report.get("gates_manifest_sha256"), + "gates": len(rows), + "status_counts": counts, + } + return rows, overview + + +def _gate_report_summary(context: ArtifactSummaryContext) -> dict: + """Per-gate outcomes for spine, full and national gate batteries. + + The spine artifact is a bare phase report; the full and national artifacts + wrap one in a document with enforcement or blocking verdicts. Gate detail + payloads and target or area tables are not projected. + """ + document = json.loads(context.payload) + if not isinstance(document, dict): + raise ValueError("Gate report artifact must be a JSON object.") + kind = document.get("kind") + if kind is None: + rows, overview = _gate_rows(document) + overview["kind"] = "gate_phase_report" + elif kind in {"uk_full_gate_report", "uk_national_gate_report"}: + if document.get("schema_version") != 1: + raise ValueError("Unsupported UK gate report schema.") + rows, overview = _gate_rows(document.get("report")) + overview["kind"] = kind + if kind == "uk_full_gate_report": + enforcement = document.get("enforcement") + if not isinstance(enforcement, dict): + raise ValueError("UK full gate report has no enforcement block.") + overview["enforcement"] = { + field: enforcement.get(field) for field in _FULL_ENFORCEMENT_FIELDS + } + overview["sample_fraction"] = document.get("sample_fraction") + overview["release_candidate"] = document.get("release_candidate") + upstream = document.get("upstream_phase_reports", {}) + if not isinstance(upstream, dict): + raise ValueError("Upstream phase reports must be an object.") + overview["upstream_phases"] = { + name: _gate_rows(payload)[1] for name, payload in upstream.items() + } + else: + blocking = document.get("blocking", []) + if not isinstance(blocking, list): + raise ValueError("National blocking gates must be a list.") + overview["blocking"] = blocking + overview["artifact_permitted"] = document.get("artifact_permitted") + overview["posture"] = document.get("posture") + overview["scope"] = document.get("scope") + else: + raise ValueError(f"Unknown UK gate report kind {kind!r}.") + return {"overview": overview, "tables": {"gates": rows}} + + UK_SUMMARY_PROVIDERS = { **CALIBRATION_SUMMARY_PROVIDERS, (RESULT_TYPE.name, RESULT_TYPE.schema_version): _uk_result_summary, (SIZE_SEARCH_TYPE.name, SIZE_SEARCH_TYPE.schema_version): _size_summary, (SIZE_DRAW_TYPE.name, SIZE_DRAW_TYPE.schema_version): _size_summary, (SIZE_RECEIPT_TYPE.name, SIZE_RECEIPT_TYPE.schema_version): _size_summary, + ( + SPINE_GATE_REPORT_TYPE.name, + SPINE_GATE_REPORT_TYPE.schema_version, + ): _gate_report_summary, + (FULL_GATE_REPORT_TYPE.name, FULL_GATE_REPORT_TYPE.schema_version): ( + _gate_report_summary + ), + ( + NATIONAL_GATE_REPORT_TYPE.name, + NATIONAL_GATE_REPORT_TYPE.schema_version, + ): _gate_report_summary, } @@ -156,75 +268,141 @@ def checkpoint_references(sidecar_path: Path | None) -> tuple[list[dict], str | if sidecar_path is None or not sidecar_path.is_file(): return [], "The checkpoint has no recorded producing graph and manifest." sidecar, _ = read_json(sidecar_path) - refs = [] - missing = [] - recorded_files = [ - ("graph_declaration", "Producing graph"), - ("graph_manifest", "Producing run manifest"), - ] - if "graph_schema" in sidecar: - recorded_files.append(("graph_schema", "Producing compiler schema")) - for name, label in recorded_files: - record = sidecar.get(name) - if ( - not isinstance(record, dict) - or not record.get("path") - or not record.get("sha256") - ): - missing.append(label) - continue - path = Path(record["path"]) - if not path.is_absolute(): - path = sidecar_path.parent / path - if not path.is_file(): - missing.append(f"{label} bytes unavailable") - elif sha256(read_json(path)[1]) != record["sha256"]: - raise ValueError(f"Recorded checkpoint {label.lower()} digest mismatch.") - refs.append( - {"label": label, "url": str(path.resolve()), "sha256": record["sha256"]} - ) - return refs, "; ".join(missing) if missing else None + return _checkpoint_references(sidecar, base=sidecar_path.parent) + + +# Presentation groups are an explicit roster, not a substring rule: a new node +# lands in "other" until it is classified here, and the maintained declarations +# are tested to contain no unclassified operation. +UK_GROUPS = ( + ("sources", "Sources", "Declared external inputs and their codecs."), + ( + "enrichment", + "Enrichment (spine stages)", + "Source assembly and imputation stages of the canonical FRS spine, with " + "their ownership boundaries.", + ), + ( + "population", + "Population pool", + "Checkpoint admission, sampling, normalisation, expansion into K " + "geographic copies and the pooled problem checkpoint.", + ), + ( + "geography", + "Geography", + "Location draws, derived geographies and the geography integrity gate.", + ), + ( + "targets", + "Targets", + "Target compilation and selection, measure evaluation and the ordered " + "calibration problem.", + ), + ( + "calibration", + "Calibration", + "Dense solve and the optional size search, draw and refit path.", + ), + ( + "checks", + "Checks and gates", + "Gate batteries and holdout diagnostics; verdicts are recorded, not inferred.", + ), + ( + "export", + "Export", + "Dataset export, read-back verification and package binding.", + ), + ("other", "Unclassified operations", "Operations not yet classified."), +) +_FULL_BUILD_GROUPS = { + "population": ( + "uk.full.spine_checkpoint", + "uk.full.sample", + "uk.full.normalize", + "uk.full.expand", + "uk.full.pool", + ), + "geography": ( + "uk.full.identity", + "uk.full.locations", + "uk.full.geography_mapping", + "uk.full.geography_gate", + "uk.full.geography.assign", + "uk.full.geography.derive", + "uk.full.geography.local_authority", + "uk.full.geography.gate", + ), + "targets": ( + "uk.full.target_compilation", + "uk.full.target_selection", + "uk.full.measures", + "uk.full.problem", + "uk.full.national_targets", + "uk.full.national_problem", + ), + "calibration": ( + "uk.full.dense", + "uk.full.size_search", + "uk.full.size_draw", + "uk.full.size_refit", + "uk.full.selected", + "uk.full.calibrated", + ), + "checks": ( + "spine.gates.assembled", + "spine.gates.transferred", + "uk.full.gates.preflight", + "uk.full.gates.calibrated", + "uk.full.holdout", + ), + "export": ( + "uk.full.export.prepare", + "uk.full.export.readback", + "uk.full.package", + "uk.full.national.readback", + ), +} +_OWNERSHIP_SUFFIXES = ("boundary", "owned", "checkpoint") +_FULL_BUILD_GROUP_OF = { + node_id: group + for group, members in _FULL_BUILD_GROUPS.items() + for node_id in members +} + + +def uk_spine_stage_ids(spec: CountrySpec | None = None) -> frozenset[str]: + """Stage node ids of the maintained spine, including its CREATE root.""" + resolved = load_country_spec("uk") if spec is None else spec + return frozenset( + {"create_uk_frs", *(stage.stage for stage in _manifest_stages(resolved))} + ) + + +def uk_operation_group(node_id: str, *, spine_stages: frozenset[str]) -> str: + """Classify one operation; an ownership helper follows its stage.""" + stem, _, suffix = node_id.rpartition(".") + base = stem if suffix in _OWNERSHIP_SUFFIXES and stem else node_id + if base in spine_stages: + return "enrichment" + return _FULL_BUILD_GROUP_OF.get(base, "other") def uk_graph_presentation( graph, *, scope: str, sidecar_path: Path | None = None, spec=None ) -> dict: """Country-owned labels and grouping; shared code owns contract validation.""" - groups = { - key: [] - for key in ( - "enrichment", - "geography", - "targets", - "calibration", - "checks", - "export", - ) - } - operations = {} ids = {node.id for node in graph.nodes} + spine_stages = uk_spine_stage_ids(spec) + members = {group: [] for group, _label, _description in UK_GROUPS} + for node in graph.nodes: + members[uk_operation_group(node.id, spine_stages=spine_stages)].append(node.id) + operations = {} # Composite descriptions come from the maintained executable stage roster. if "create_uk_frs" in ids: for item in uk_spine_operation_inventory(graph, spec): operations[item["node"]] = {"composite": item} - for node in graph.nodes: - if "gates" in node.id or "readback" in node.id or "certification" in node.id: - group = "checks" - elif node.id.startswith("uk.full.export") or node.id == "uk.full.package": - group = "export" - elif node.id.startswith( - ("uk.full.dense", "uk.full.size", "uk.full.selected", "uk.full.calibrated") - ): - group = "calibration" - elif "target" in node.id or "problem" in node.id: - group = "targets" - elif node.id.startswith("uk.full.") and any( - part in node.id for part in ("geograph", "clone", "support", "atomic") - ): - group = "geography" - else: - group = "enrichment" - groups[group].append(node.id) scope_record = {"id": scope, "label": scope.replace("_", " ")} if "uk.full.spine_checkpoint" in ids and any( source.name == "uk_spine" for source in graph.sources @@ -238,21 +416,31 @@ def uk_graph_presentation( if missing: boundary["missing"] = missing scope_record["boundaries"] = [boundary] + groups = [] + for group, label, description in UK_GROUPS: + if group == "sources": + if graph.sources: + groups.append( + { + "id": group, + "label": label, + "description": description, + "sources": [source.name for source in graph.sources], + } + ) + elif members[group]: + groups.append( + { + "id": group, + "label": label, + "description": description, + "operations": members[group], + } + ) return { "protocol": PRESENTATION_PROTOCOL, "scope": scope_record, - "groups": [ - { - "id": "sources", - "label": "Sources", - "sources": [source.name for source in graph.sources], - }, - *( - {"id": key, "label": key.capitalize(), "operations": members} - for key, members in groups.items() - if members - ), - ], + "groups": groups, "operations": operations, } @@ -263,54 +451,22 @@ def save_uk_graph_schema( contract = uk_graph_presentation( graph, scope=scope, sidecar_path=sidecar_path, spec=spec ) - # Copy only explicitly recorded graph/manifest bytes, so links survive the - # temporary build directory's publication and the HTML can sit beside them. - for boundary in contract["scope"].get("boundaries", []): - for ref in boundary["upstream"]: - source = Path(ref["url"]) - if source.is_file(): - relative = Path(f"upstream-{ref['sha256']}.json") - destination = Path(directory) / relative - destination.parent.mkdir(parents=True, exist_ok=True) - payload = read_json(source)[1] - if sha256(payload) != ref["sha256"]: - raise ValueError("Checkpoint reference changed during capture.") - destination.write_bytes(payload) - ref["url"] = relative.as_posix() - schema = graph_schema( - compile_graph(graph), extensions={PRESENTATION_EXTENSION: contract} - ) - path = Path(directory) / "graph.schema.json" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(canonical_json(schema)) - return path + return save_graph_schema(graph, Path(directory), presentation=contract) def persist_uk_run_evidence(graph, manifest, store, args, *, phase: str) -> Path: """Preserve native evidence; the operator separately requests Orrery export.""" attempt_id = Path(args.attempt_evidence).name - durable = Path(args.attempt_evidence) / "graph-evidence" index_path = save_run_evidence( compile_graph(graph), manifest, store=store, - directory=durable, + directory=Path(args.attempt_evidence) / "graph-evidence", attempt_id=attempt_id, phase=phase, artifact_summaries=UK_SUMMARY_PROVIDERS, ) # The build publisher transacts over files in one directory. Keep the - # published evidence flat, while retaining the durable attempt bundle. - index, _ = read_json(index_path) - for entry in index["runs"]: - for field in ("graph", "manifest", "binding", "summaries"): - relative = Path(entry[field]["path"]) - filename = f"evidence-{attempt_id}-{relative.parent.name}-{field}.json" - payload = read_json(durable / relative)[1] - if sha256(payload) != entry[field]["sha256"]: - raise ValueError("Native phase evidence changed during capture.") - (args.out / filename).write_bytes(payload) - entry[field]["path"] = filename - destination = args.out / "execution.evidence.json" - destination.write_bytes(canonical_json(index)) - return destination + # published evidence flat and merged across attempts, while retaining the + # durable attempt bundle. + return publish_run_evidence(index_path, Path(args.out)) diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py index 33d4a40b0..97dc9c518 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py @@ -136,7 +136,10 @@ UK_SAMPLE_SEED_DEFAULT, ) from microcosm.build.uk_runtime.nts_bus_travel import UKNTSBusTravelStageTransform -from microcosm.build.uk_runtime.orrery_contract import save_uk_graph_schema +from microcosm.build.uk_runtime.orrery_contract import ( + UK_SUMMARY_PROVIDERS, + save_uk_graph_schema, +) from microcosm.build.uk_runtime.pension_credit_take_up import ( UKPensionCreditTakeUpStageTransform, ) @@ -1917,6 +1920,7 @@ def main(argv: list[str] | None = None) -> int: / (graph_attempt_id := uuid.uuid4().hex), attempt_id=graph_attempt_id, phase="spine", + artifact_summaries=UK_SUMMARY_PROVIDERS, ) graph_run_reference = json.loads(graph_evidence_path.read_text())["runs"][-1] graph_declaration_path = ( diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py index fa44e575d..e1c7f5196 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py @@ -333,6 +333,19 @@ def test_orrery_summary_replays_dense_and_size_artifacts_without_solving( assert size["method"] == ( "full_pool" if k == 3 else "contribution_informed_l0_exact_count_refit" ) + preflight = next( + item["data"] + for item in evidence["summaries"] + if item["node"] == "pool" and item["artifact"] == "preflight" + ) + assert preflight["overview"]["kind"] == "uk_full_gate_report" + assert preflight["overview"]["phase"] == "preflight" + assert preflight["overview"]["enforcement"]["artifact_permitted"] is True + assert preflight["overview"]["status_counts"]["passed"] == len( + preflight["tables"]["gates"] + ) + assert {row["status"] for row in preflight["tables"]["gates"]} == {"passed"} + assert "details" not in preflight["tables"]["gates"][0] serialized = json.dumps(evidence) assert '"household_ids":' not in serialized assert '"pool_row_indices":' not in serialized @@ -344,6 +357,22 @@ def test_orrery_summary_replays_dense_and_size_artifacts_without_solving( if json.loads(node["id"]) == ["operation", endpoints.result_producer] ) assert result_node["data"]["artifact_summaries"] + pool = next( + node + for node in document["nodes"] + if json.loads(node["id"]) == ["operation", "pool"] + ) + assert pool["data"]["artifact_summaries"]["preflight"]["tables"]["gates"]["rows"] + assert [badge["label"] for badge in pool["statuses"]] == [ + "Execution: completed", + "Computed: numerical", + ] + single_file = [ + artifact + for artifact in document["artifacts"] + if artifact["label"].endswith("(bytes, 1 file)") + ] + assert single_file and all(len(a["sha256"]) == 64 for a in single_file) def test_reused_draw_skips_rng_and_rejects_changed_binding(monkeypatch): diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py index e24a25b08..b64521316 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_orrery_contract.py @@ -9,11 +9,19 @@ from microcosm.build.uk_runtime.graph_build import UKFullBuildConfig, uk_full_graph from microcosm.build.uk_runtime.graph_calibration import UKGraphCalibrationConfig from microcosm.build.uk_runtime.graph_evidence import add_uk_spine_gate_nodes +from microcosm.build.uk_runtime.graph_national import ( + NATIONAL_GATES_NODE, + NATIONAL_PROBLEM_NODE, + NATIONAL_READBACK_NODE, + NATIONAL_TARGETS_NODE, +) from microcosm.build.uk_runtime.graph_terminal import append_uk_full_gate_nodes from microcosm.build.uk_runtime.orrery_contract import ( checkpoint_references, save_uk_graph_schema, uk_graph_presentation, + uk_operation_group, + uk_spine_stage_ids, ) from microcosm.graph import ( Graph, @@ -90,6 +98,39 @@ def test_complete_dense_and_size_declarations_fit_existing_export_limits(househo assert len(document["nodes"]) < 20_000 assert len(document["edges"]) < 100_000 assert document["metadata"]["presentation_scope"]["id"] == "full_dense" + assert json.loads(document["id"]) == ["microcosm", "uk", "full_dense"] + assert document["title"] == "UK · full dense" + # Every maintained operation is explicitly rostered; nothing falls through. + groups = {group["id"]: group for group in contract["groups"]} + assert "other" not in groups + assert set(groups) == { + "sources", + "enrichment", + "population", + "geography", + "targets", + "calibration", + "checks", + } + assert all(group["description"] for group in groups.values()) + assert set(groups["enrichment"]["operations"]) == { + node.id for node in spine.nodes if not node.id.startswith("spine.gates.") + } + assert {"uk.full.pool", "uk.full.expand", "uk.full.expand.owned"} <= set( + groups["population"]["operations"] + ) + assert set(groups["checks"]["operations"]) >= { + "spine.gates.assembled", + "uk.full.gates.preflight", + "uk.full.gates.calibrated", + "uk.full.holdout", + } + if households is not None: + assert {"uk.full.size_search", "uk.full.selected"} <= set( + groups["calibration"]["operations"] + ) + fields = [node for node in document["nodes"] if node["kind"] == "field"] + assert all(json.loads(node["parentId"])[0] == "operation" for node in fields) for node_id in ("uk.full.gates.preflight", "uk.full.gates.calibrated"): operation = next(node for node in document["nodes"] if node["label"] == node_id) assert ( @@ -159,3 +200,71 @@ def test_checkpoint_links_use_recorded_bytes_and_survive_output_relocation(tmp_p graph.write_bytes(b'{"original":false}') with pytest.raises(ValueError, match="digest mismatch"): checkpoint_references(sidecar) + + +def test_national_and_export_operations_are_rostered(): + spine = uk_spine_stage_ids() + assert uk_operation_group("create_uk_frs", spine_stages=spine) == "enrichment" + assert uk_operation_group("was_wealth", spine_stages=spine) == "enrichment" + assert uk_operation_group("frs_brma.checkpoint", spine_stages=spine) == "enrichment" + assert uk_operation_group(NATIONAL_TARGETS_NODE, spine_stages=spine) == "targets" + assert uk_operation_group(NATIONAL_PROBLEM_NODE, spine_stages=spine) == "targets" + assert uk_operation_group(NATIONAL_GATES_NODE, spine_stages=spine) == "checks" + assert uk_operation_group(NATIONAL_READBACK_NODE, spine_stages=spine) == "export" + for node_id in ( + "uk.full.export.prepare", + "uk.full.export.readback", + "uk.full.package", + ): + assert uk_operation_group(node_id, spine_stages=spine) == "export" + assert uk_operation_group("uk.full.spine_checkpoint", spine_stages=spine) == ( + "population" + ) + assert uk_operation_group("uk.full.invented", spine_stages=spine) == "other" + contract = uk_graph_presentation(checkpoint_graph(), scope="national") + assert [group["id"] for group in contract["groups"]] == ["sources", "population"] + + +def test_unavailable_checkpoint_bytes_are_named_not_linked(tmp_path): + sidecar = tmp_path / "spine.build.json" + graph = tmp_path / "spine.graph-declaration.json" + graph.write_bytes(b'{"original":true}') + evidence = tmp_path / "execution.evidence.json" + evidence.write_bytes(b'{"protocol":"x","runs":[]}') + sidecar.write_text( + json.dumps( + { + "graph_declaration": { + "path": str(graph), + "sha256": sha256(graph.read_bytes()), + }, + "graph_manifest": { + "path": str(tmp_path / "gone.json"), + "sha256": "a" * 64, + }, + "graph_execution_evidence": { + "path": str(evidence), + "sha256": sha256(evidence.read_bytes()), + }, + } + ) + ) + contract = uk_graph_presentation( + checkpoint_graph(), scope="checkpoint_dense", sidecar_path=sidecar + ) + boundary = contract["scope"]["boundaries"][0] + assert [ref["label"] for ref in boundary["upstream"]] == [ + "Producing graph declaration", + "Producing execution evidence index", + ] + assert "bytes unavailable" in boundary["missing"] + assert "a" * 64 in boundary["missing"] + assert not any("gone.json" in json.dumps(ref) for ref in boundary["upstream"]) + saved = save_uk_graph_schema( + checkpoint_graph(), tmp_path / "out", scope="national", sidecar_path=sidecar + ) + document = orrery_document_from_schema(json.loads(saved.read_bytes())) + node = next(node for node in document["nodes"] if node["kind"] == "operation") + assert node["statuses"][0]["label"] == "Upstream evidence missing" + assert all(ref["url"].startswith("upstream-") for ref in node["sources"]) + assert "gone.json" not in saved.read_text() diff --git a/packages/microcosm-graph/src/microcosm/graph/__init__.py b/packages/microcosm-graph/src/microcosm/graph/__init__.py index d0db5eb0a..28dde48d3 100644 --- a/packages/microcosm-graph/src/microcosm/graph/__init__.py +++ b/packages/microcosm-graph/src/microcosm/graph/__init__.py @@ -198,9 +198,12 @@ def orrery_json( from .evidence import ( # noqa: E402 ArtifactSummaryContext, RecordedRun, + checkpoint_references, collect_execution_evidence, load_run_evidence, + publish_run_evidence, record_run_binding, + save_graph_schema, save_run_evidence, ) from .executor import NodeRejected, run_graph # noqa: E402 @@ -221,8 +224,11 @@ def orrery_json( __all__ += [ "ArtifactSummaryContext", "RecordedRun", + "checkpoint_references", "collect_execution_evidence", "load_run_evidence", + "publish_run_evidence", "record_run_binding", + "save_graph_schema", "save_run_evidence", ] diff --git a/packages/microcosm-graph/src/microcosm/graph/evidence.py b/packages/microcosm-graph/src/microcosm/graph/evidence.py index bbe60db77..e2ecc7845 100644 --- a/packages/microcosm-graph/src/microcosm/graph/evidence.py +++ b/packages/microcosm-graph/src/microcosm/graph/evidence.py @@ -15,12 +15,14 @@ from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path +from urllib.parse import urlsplit from .availability import execution_state, unavailable_artifacts from .canonical import canonical_json, normative from .decl import ( GATE_OUTCOMES, CompiledGraph, + Graph, GraphError, StructuralDelta, compile_graph, @@ -36,8 +38,13 @@ weights_key, ) from .manifest import RunManifest -from .presentation import digest, require, text -from .schema import _plain_json, _require_compiler_result, validate_graph_schema +from .presentation import PRESENTATION_EXTENSION, digest, require, text +from .schema import ( + _plain_json, + _require_compiler_result, + graph_schema, + validate_graph_schema, +) from .serialize import graph_from_json, graph_to_json from .store import ContentStore @@ -438,6 +445,7 @@ def collect_execution_evidence( require(gate in GATE_OUTCOMES, "invalid gate outcome") operations[node_id] = { "node_key": receipt.key, + "kernel_role": receipt.capabilities.role.value, "execution": "unreached" if state == "unreached" else "failed" @@ -665,3 +673,190 @@ def load_run_evidence( ) runs.append(run) return tuple(runs) + + +CHECKPOINT_PROVENANCE_FIELDS = ( + ("graph_declaration", "Producing graph declaration", True), + ("graph_manifest", "Producing run manifest", True), + ("graph_schema", "Producing compiler schema", False), + ("graph_execution_evidence", "Producing execution evidence index", False), +) + + +def checkpoint_references( + record: object, *, base: Path +) -> tuple[list[dict], str | None]: + """Project a checkpoint's recorded producing-run files into upstream references. + + ``record`` is the producer-written provenance mapping (for example a + checkpoint sidecar) whose ``graph_declaration``, ``graph_manifest`` and + optional ``graph_schema`` / ``graph_execution_evidence`` entries each carry + ``path`` and ``sha256``. Only files whose bytes still match their recorded + digest become references with a local URL; absent files are named, with + their recorded digest, in the returned missing reason rather than linked. + A digest mismatch refuses the projection. Nothing is reconstructed from + today's declarations. + """ + require(isinstance(record, Mapping), "checkpoint provenance must be a mapping") + refs: list[dict] = [] + missing: list[str] = [] + for name, label, required in CHECKPOINT_PROVENANCE_FIELDS: + entry = record.get(name) + if entry is None and not required: + continue + if ( + not isinstance(entry, Mapping) + or not entry.get("path") + or not entry.get("sha256") + ): + missing.append(label) + continue + recorded = digest(entry["sha256"]) + path = Path(text(entry["path"], f"{name} path")) + if not path.is_absolute(): + path = Path(base) / path + if not path.is_file(): + missing.append(f"{label} bytes unavailable (sha256 {recorded})") + continue + require( + sha256(read_json(path)[1]) == recorded, + f"recorded checkpoint {label.lower()} digest mismatch", + ) + refs.append({"label": label, "url": str(path.resolve()), "sha256": recorded}) + return refs, "; ".join(missing) if missing else None + + +def save_graph_schema( + graph: Graph | CompiledGraph, + directory: Path, + *, + presentation: Mapping[str, object] | None = None, + filename: str = "graph.schema.json", +) -> Path: + """Write the compiler schema with its presentation contract beside a build. + + Upstream checkpoint references that name local files are copied into the + directory as ``upstream-.json`` (after re-checking their digest) + and relinked relatively, so the schema and any later Orrery export can be + published or moved together with the recorded bytes. + """ + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + extensions = None + if presentation is not None: + contract = _plain(dict(presentation)) + require(type(contract) is dict, "presentation must be an object") + scope = contract.get("scope", {}) + boundaries = scope.get("boundaries", []) if type(scope) is dict else [] + for boundary in boundaries if type(boundaries) is list else []: + upstream = boundary.get("upstream", []) if type(boundary) is dict else [] + for ref in upstream if type(upstream) is list else []: + url = ref.get("url") if type(ref) is dict else None + if type(url) is not str or urlsplit(url).scheme: + continue + source = Path(url) + if not source.is_absolute() or not source.is_file(): + continue + payload = read_json(source)[1] + require( + sha256(payload) == digest(ref.get("sha256")), + "checkpoint reference changed during capture", + ) + relative = Path(f"upstream-{ref['sha256']}.json") + (directory / relative).write_bytes(payload) + ref["url"] = relative.as_posix() + extensions = {PRESENTATION_EXTENSION: contract} + compiled = compile_graph(graph) if isinstance(graph, Graph) else graph + schema = graph_schema(compiled, extensions=extensions) + path = directory / filename + _atomic_json(path, schema) + return path + + +def publish_run_evidence( + index_path: Path, out_dir: Path, *, prefix: str = "evidence" +) -> Path: + """Flatten a durable attempt bundle into one publisher directory. + + The published ``execution.evidence.json`` keeps every previously published + attempt whose flat files still verify, so a repeated or resumed build into + the same output directory does not erase the record of earlier computation + behind a replay of cache hits. Published files that no longer match their + recorded digests refuse publication instead of being dropped silently. + """ + index_path = Path(index_path) + out_dir = Path(out_dir) + text(prefix, "prefix") + require("/" not in prefix and ".." not in prefix, "prefix must be a file stem") + index, _ = read_json(index_path) + require( + type(index) is dict + and index.get("protocol") == EVIDENCE_INPUT_PROTOCOL + and type(index.get("runs")) is list, + "evidence index", + ) + attempts = {text(entry["attempt_id"], "attempt id") for entry in index["runs"]} + for attempt in attempts: + require("/" not in attempt and ".." not in attempt, "attempt id") + destination = out_dir / "execution.evidence.json" + runs: list[dict] = [] + if destination.exists(): + published, _ = read_json(destination) + require( + type(published) is dict + and published.get("protocol") == EVIDENCE_INPUT_PROTOCOL + and type(published.get("runs")) is list, + "published evidence index", + ) + for entry in published["runs"]: + require( + type(entry) is dict + and set(entry) + == {"attempt_id", "phase", "graph", "manifest", "binding", "summaries"}, + "published evidence run fields", + ) + if entry["attempt_id"] in attempts: + continue + for field in ("graph", "manifest", "binding", "summaries"): + ref = entry[field] + relative = Path(text(ref["path"], "published evidence path")) + require( + len(relative.parts) == 1 and not relative.is_absolute(), + "published evidence path", + ) + target = out_dir / relative + require( + target.is_file(), + f"published evidence file {relative} is missing; " + "restore it or clear the published evidence", + ) + require( + sha256(read_json(target)[1]) == digest(ref["sha256"]), + f"published evidence file {relative} no longer matches its digest", + ) + runs.append(entry) + out_dir.mkdir(parents=True, exist_ok=True) + for entry in index["runs"]: + published_entry = {"attempt_id": entry["attempt_id"], "phase": entry["phase"]} + for field in ("graph", "manifest", "binding", "summaries"): + relative = Path(text(entry[field]["path"], "evidence path")) + require( + not relative.is_absolute() and ".." not in relative.parts, + "evidence path must stay in bundle", + ) + filename = ( + f"{prefix}-{entry['attempt_id']}-{relative.parent.name}-{field}.json" + ) + payload = read_json(index_path.parent / relative)[1] + require( + sha256(payload) == digest(entry[field]["sha256"]), + "native phase evidence changed during capture", + ) + (out_dir / filename).write_bytes(payload) + published_entry[field] = { + "path": filename, + "sha256": entry[field]["sha256"], + } + runs.append(published_entry) + _atomic_json(destination, {"protocol": EVIDENCE_INPUT_PROTOCOL, "runs": runs}) + return destination diff --git a/packages/microcosm-graph/src/microcosm/graph/orrery.py b/packages/microcosm-graph/src/microcosm/graph/orrery.py index aae8e5b27..f68b859f0 100644 --- a/packages/microcosm-graph/src/microcosm/graph/orrery.py +++ b/packages/microcosm-graph/src/microcosm/graph/orrery.py @@ -175,13 +175,37 @@ def orrery_document_from_schema( versions = _object(compiled.get("versions"), "compiled versions") predecessors = _object(compiled.get("predecessors"), "compiled predecessors") declarations = _owned_declarations(operations) - transport_document = _transport_json(document) + contract = validate_presentation(document) + scope = (contract or {}).get("scope", {}) + country = _text(document["country"], "country") + # Records carry their own declarations; the complete compiler schema is the + # separately saved canonical file, bound here by digest rather than embedded. + schema_summary = _object( + _transport_json( + { + "protocol": document["protocol"], + "country": country, + "graph_sha256": document["graph_sha256"], + "schema_sha256": hashlib.sha256(canonical_json(document)).hexdigest(), + "compiled_order": list(_array(compiled.get("order"), "compiled order")), + "counts": { + "operations": len(operations), + "sources": len(sources), + "fields": len(_array(document["fields"], "fields")), + "input_bindings": len( + _array(document["input_bindings"], "input bindings") + ), + }, + "extensions": document["extensions"], + } + ), + "schema summary", + ) nodes: dict[str, dict[str, object]] = {} edges: dict[str, dict[str, object]] = {} used = ( - len(_bounded_json(transport_document, _MAX_OUTPUT_BYTES).encode("utf-8")) - + 65_536 + len(_bounded_json(schema_summary, _MAX_OUTPUT_BYTES).encode("utf-8")) + 65_536 ) def presentation_record(record: dict[str, object]) -> dict[str, object]: @@ -204,6 +228,7 @@ def add_edge( kind: str, category: str, data: dict[str, object] | None = None, + label: str | None = None, ) -> None: facts = {} if data is None else data identity = presentation_id( @@ -212,16 +237,17 @@ def add_edge( if identity in edges: return _require(len(edges) < _MAX_EDGES, "edge limit") - edges[identity] = presentation_record( - { - "id": identity, - "source": source, - "target": target, - "kind": kind, - "category": category, - "data": facts, - } - ) + record = { + "id": identity, + "source": source, + "target": target, + "kind": kind, + "category": category, + "data": facts, + } + if label is not None: + record["label"] = label + edges[identity] = presentation_record(record) for node_id, operation in operations.items(): node: dict[str, object] = { @@ -263,18 +289,25 @@ def add_field(field: dict[str, object], *, visible_field: bool) -> str: ) if identity not in fields: fields[identity] = field + provider = _text(field.get("provider"), "field provider") + population = _text(field.get("population"), "field population") + description = f"Population version {population}; provided by {provider}" + if declared_in != provider: + description += f"; declared in {declared_in}" add_node( { "id": identity, "label": f"{entity}.{column}", "kind": "field", + # Containment under the provider keeps a folded operation's + # versioned fields folded with it; provenance stays an edge. + "parentId": presentation_id("operation", provider), + "description": description, "data": {**field, "visible_in_schema": visible_field}, } ) add_edge( - presentation_id( - "operation", _text(field.get("provider"), "field provider") - ), + presentation_id("operation", provider), identity, "provided_field", "provenance", @@ -313,6 +346,8 @@ def add_field(field: dict[str, object], *, visible_field: bool) -> str: "declared_read", "dependency", {"read_kind": binding["kind"], "rows": binding["rows"]}, + label=f"{_text(binding['kind'], 'binding kind').replace('_', ' ')}" + f" · rows {_text(binding['rows'], 'binding rows')}", ) for node_id, operation in operations.items(): @@ -364,12 +399,15 @@ def add_field(field: dict[str, object], *, visible_field: bool) -> str: ), "dangling presentation edge", ) - country = _text(document["country"], "country") + scope_id = scope.get("id") if type(scope) is dict else None + scope_label = scope.get("label") if type(scope) is dict else None result: dict[str, object] = { "schemaVersion": "graph-explorer/v1", - "id": presentation_id("microcosm", country), + "id": presentation_id("microcosm", country, *([scope_id] if scope_id else [])), "title": _text(title, "title") if title is not None + else f"{country.upper()} · {scope_label or scope_id}" + if scope_id or scope_label else f"{country.upper()} graph schema", "revision": _revision(document), "description": ( @@ -394,7 +432,17 @@ def add_field(field: dict[str, object], *, visible_field: bool) -> str: "Declared operation reads and compiler dependencies; not " "individual-output formulas or observed runtime access." ), - "microcosm": transport_document, + "containment_scope": ( + "Groups contain operations and sources; a field is contained by " + "the operation whose artifact provides it. Containment folds the " + "canvas and asserts no value dependency." + ), + "schema_scope": ( + "Records carry their declarations; the complete compiler schema " + "is the separately saved canonical graph.schema.json whose " + "bytes have metadata.microcosm.schema_sha256." + ), + "microcosm": schema_summary, "missing_runtime_data": [ "entity identifiers and memberships", "field values", diff --git a/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py b/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py index 7a72fd5a4..9459c779a 100644 --- a/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py +++ b/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py @@ -20,7 +20,6 @@ def apply_presentation(document: dict, schema: dict) -> None: if contract is None: return nodes = {node["id"]: node for node in document["nodes"]} - membership = {} for group in contract.get("groups", []): node = { "id": presentation_id("group", group["id"]), @@ -35,16 +34,7 @@ def apply_presentation(document: dict, schema: dict) -> None: nodes[node["id"]] = node for plural, kind in (("operations", "operation"), ("sources", "source")): for member in group.get(plural, []): - identity = presentation_id(kind, member) - membership[identity] = node["id"] - nodes[identity]["parentId"] = node["id"] - for node in nodes.values(): - if node["kind"] == "field": - group = membership.get( - presentation_id("operation", node["data"]["provider"]) - ) - if group is not None: - node["parentId"] = group + nodes[presentation_id(kind, member)]["parentId"] = node["id"] for plural, kind in (("operations", "operation"), ("sources", "source")): for identity, record in contract.get(plural, {}).items(): node = nodes[presentation_id(kind, identity)] @@ -103,7 +93,7 @@ def apply_execution(document: dict, schema: dict, execution: object) -> None: activities = [] artifacts = {} seen = set() - latest = {} + history = {} summary_catalog = {} for summary in execution["summaries"]: key = digest(summary["key"]) @@ -155,19 +145,34 @@ def apply_execution(document: dict, schema: dict, execution: object) -> None: "execution_history", [] ).append({"attempt_id": attempt, "phase": name, "content_identity": key}) artifact_ids = {} + produced = {} for artifact in phase["artifacts"]: key = digest(artifact["key"]) - require(type(artifact["payloads"]) is dict, "artifact payload table") - ids = [] - for filename, payload in artifact["payloads"].items(): - identity = presentation_id("artifact", key, filename) - artifacts[identity] = { - "id": identity, - "label": f"{artifact['producers'][0]['node']}.{artifact['producers'][0]['name']}: {filename}", - "sha256": digest(payload["sha256"]), - } - ids.append(identity) - artifact_ids[key] = ids + payloads = artifact["payloads"] + require(type(payloads) is dict and payloads, "artifact payload table") + require( + type(artifact["producers"]) is list and artifact["producers"], + "artifact producers", + ) + producer = artifact["producers"][0] + files = len(payloads) + # One reference per content-store object. Per-file digests stay in + # metadata.execution.phases[].artifacts; a single-file object keeps + # its byte digest on the reference itself. + identity = presentation_id("artifact", key) + reference = { + "id": identity, + "label": f"{producer['node']}.{producer['name']} " + f"({artifact['kind']}, {files} file{'s' if files != 1 else ''})", + } + if files == 1: + reference["sha256"] = digest(next(iter(payloads.values()))["sha256"]) + artifacts[identity] = reference + artifact_ids[key] = [identity] + for item in artifact["producers"]: + produced.setdefault(text(item["node"], "producer node"), {})[ + text(item["name"], "producer name") + ] = key require(type(phase["references"]) is dict, "phase references") refs = [] phase_artifacts = [] @@ -213,6 +218,8 @@ def apply_execution(document: dict, schema: dict, execution: object) -> None: identity = presentation_id("operation", node_id) node = nodes[identity] details = {"attempt_id": attempt, "phase": name, **record} + if node_id in produced: + details["artifacts"] = dict(sorted(produced[node_id].items())) if node_id in summaries: details["artifact_summary_keys"] = [ item["key"] for item in summaries[node_id] @@ -234,9 +241,7 @@ def apply_execution(document: dict, schema: dict, execution: object) -> None: previews[item["artifact"]] = {"key": item["key"], **preview} node["data"]["artifact_summaries"] = previews node["data"].setdefault("execution_history", []).append(details) - if refs: - node.setdefault("sources", []).extend(refs) - latest[node_id] = record + history.setdefault(node_id, []).append(details) own_artifacts = [ identity for artifact in phase["artifacts"] @@ -285,40 +290,29 @@ def apply_execution(document: dict, schema: dict, execution: object) -> None: ): if phase[timestamp]: activity[prop] = phase[timestamp] + if record.get("kernel_ref"): + activity["agent"] = {"name": text(record["kernel_ref"], "kernel")} + if record.get("kernel_impl_hash"): + activity["agent"]["version"] = digest(record["kernel_impl_hash"]) activities.append(activity) for node_id in sorted(operation_ids): node = nodes[presentation_id("operation", node_id)] - record = latest.get( - node_id, {"execution": "missing", "cache": "unknown", "gate": "unknown"} - ) - statuses = node.setdefault("statuses", []) - statuses.append( - { - "label": f"Execution: {record['execution']}", - "tone": "negative" - if record["execution"] == "failed" - else "warning" - if record["execution"] in {"missing", "unreached"} - else "positive", - } - ) - statuses.append({"label": f"Cache: {record['cache']}", "tone": "neutral"}) - statuses.append( - { - "label": f"Gate: {record['gate']}", - "tone": "positive" - if record["gate"] == "pass" - else "negative" - if record["gate"] == "fail" - else "warning" - if record["gate"] in {"evidence_absent", "unreached", "unknown"} - else "neutral", - } - ) + node.setdefault("statuses", []).extend(_status_badges(history.get(node_id, []))) document["activities"] = activities document["artifacts"] = [artifacts[key] for key in sorted(artifacts)] document["metadata"]["execution"] = execution document["metadata"]["evidence_scope"] = execution["verification"] + document["metadata"]["artifact_scope"] = ( + "One artifact reference per content-store object, labelled by its " + "producing operation and output; per-file payload digests are in " + "metadata.execution.phases[].artifacts." + ) + document["metadata"]["status_scope"] = ( + "Gate badges appear only on gate kernels. The cache badge summarises " + "every supplied phase: a computing phase is named before replays are " + "counted, and a reuse-only badge does not claim the operation never " + "computed earlier." + ) document["metadata"]["revision_scope"] = ( "Content digests of declarations, presentation and supplied execution records; " "not runtime cache keys." @@ -334,6 +328,68 @@ def apply_execution(document: dict, schema: dict, execution: object) -> None: document["revision"] = "sha256:" + sha256(canonical_json([schema, execution])) +def _status_badges(records: list[dict]) -> list[dict]: + """Independent badges summarising every supplied phase of one operation. + + Orrery cards show the first two badges, so the gate verdict leads on gate + kernels and the execution state follows. The cache badge names the phase + that computed the result before counting replays; a reuse-only badge is + explicit that no supplied phase computed it. + """ + recorded = [record for record in records if record["execution"] != "missing"] + latest = records[-1] if records else {"execution": "missing"} + badges = [] + gates = [record for record in recorded if record.get("kernel_role") == "gate"] + if gates: + gate = gates[-1]["gate"] + badges.append( + { + "label": f"Gate: {gate}", + "tone": "positive" + if gate == "pass" + else "negative" + if gate == "fail" + else "warning" + if gate in {"evidence_absent", "unreached", "unknown"} + else "neutral", + } + ) + execution = latest["execution"] + badges.append( + { + "label": f"Execution: {execution}", + "tone": "negative" + if execution == "failed" + else "warning" + if execution in {"missing", "unreached"} + else "positive", + } + ) + computed = [ + record + for record in recorded + if record["cache"] == "miss" and record["execution"] in {"completed", "failed"} + ] + reused = [record for record in recorded if record["cache"] == "hit"] + if computed: + label = ( + f"Computed: {computed[0]['phase']}" + if len(computed) == 1 + else f"Computed: {len(computed)} phases" + ) + if reused: + label += f" · reused {len(reused)}×" + elif reused: + label = ( + f"Reused: {len(reused)} cache hit" + f"{'s' if len(reused) != 1 else ''}; computation not in supplied phases" + ) + else: + label = "Cache: unknown" + badges.append({"label": label, "tone": "neutral"}) + return badges + + def apply_contracts(document: dict, schema: dict, execution: object | None) -> None: apply_presentation(document, schema) if execution is not None: diff --git a/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py b/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py index a408b348b..360be2468 100644 --- a/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py +++ b/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py @@ -7,18 +7,24 @@ import subprocess import sys from dataclasses import replace +from pathlib import Path import pytest from microcosm.graph import ( + checkpoint_references, collect_execution_evidence, graph_schema, load_run_evidence, orrery_document, orrery_json, + publish_run_evidence, record_run_binding, + save_graph_schema, save_run_evidence, ) +from microcosm.graph.canonical import canonical_json +from microcosm.graph.evidence import sha256 from microcosm.graph.orrery import main from microcosm.graph.presentation import PRESENTATION_EXTENSION, PRESENTATION_PROTOCOL from microcosm.graph.store import StoreCorrupt, StoreMiss @@ -96,8 +102,44 @@ def test_fresh_and_cache_phases_keep_same_ids_and_independent_states(fresh, tmp_ history = operation["data"]["execution_history"] assert [record["cache"] for record in history] == ["miss", "hit"] assert {record["execution"] for record in history} == {"completed"} + assert {record["kernel_role"] for record in history} == {"compute"} + assert history[0]["artifacts"] + # Cards show two badges: the execution state and an attempt-level cache + # summary that names the computing phase before counting replays. + assert [badge["label"] for badge in operation["statuses"]] == [ + "Execution: completed", + "Computed: numerical · reused 1×", + ] + gate = next(node for node in document["nodes"] if node["label"] == "gate_tax") + assert [badge["label"] for badge in gate["statuses"]][:2] == [ + "Gate: pass", + "Execution: completed", + ] + assert not any( + badge["label"].startswith("Gate:") + for node in document["nodes"] + if node["kind"] == "operation" and node["label"] != "gate_tax" + for badge in node["statuses"] + ) + for node in document["nodes"]: + if node["kind"] == "field": + assert json.loads(node["parentId"])[0] == "operation" + if node["kind"] == "operation": + assert "sources" not in node assert len(document["activities"]) == 2 * len(fresh.compiled.order) + assert {activity["agent"]["name"] for activity in document["activities"]} == { + node.kernel for node in fresh.compiled.graph.nodes + } assert document["artifacts"] + labels = [artifact["label"] for artifact in document["artifacts"]] + assert all(label.endswith(("file)", "files)")) for label in labels) + assert len(document["artifacts"]) == len( + { + artifact["key"] + for phase in evidence["phases"] + for artifact in phase["artifacts"] + } + ) assert "receipts" not in document assert document == orrery_document( fresh.compiled, extensions=schema["extensions"], execution=evidence @@ -234,10 +276,25 @@ def test_saved_evidence_roundtrip_and_cli(fresh, tmp_path): assert not (fresh.store.root / "tmp").exists() document = json.loads(output.read_text()) assert document["activities"] - assert all(len(artifact["sha256"]) == 64 for artifact in document["artifacts"]) - assert next(node for node in document["nodes"] if node["label"] == "survey")[ - "sources" - ] + run_files = [artifact for artifact in document["artifacts"] if artifact.get("uri")] + assert {artifact["label"] for artifact in run_files} == { + f"p: {name}" for name in ("graph", "manifest", "binding", "summaries") + } + assert all(len(artifact["sha256"]) == 64 for artifact in run_files) + assert all( + {artifact["id"] for artifact in run_files} <= set(activity["artifactIds"]) + for activity in document["activities"] + ) + # A multi-file store object has no single byte digest to declare; its + # per-file digests stay in metadata.execution.phases[].artifacts. + for artifact in document["artifacts"]: + if artifact["label"].endswith("files)"): + assert "sha256" not in artifact + assert all( + {payload["sha256"] for payload in artifact["payloads"].values()} + for phase in document["metadata"]["execution"]["phases"] + for artifact in phase["artifacts"] + ) with pytest.raises(SystemExit): main( [ @@ -424,3 +481,142 @@ def test_public_orrery_parser_accepts_groups_and_execution(fresh): ["node", str(script)], input=payload, text=True, capture_output=True, check=True ) assert "accepted" in result.stdout + + +def test_publish_run_evidence_merges_attempts_and_refuses_corrupt_files( + fresh, tmp_path +): + out = tmp_path / "out" + first = save_run_evidence( + fresh.compiled, + fresh.manifest, + store=fresh.store, + directory=tmp_path / "attempt-1", + attempt_id="attempt-1", + phase="numerical", + ) + publish_run_evidence(first, out) + cached = toy.run_toy( + fresh.compiled.graph, + tmp_path, + sources=fresh.sources, + registry=fresh.registry, + store=fresh.store, + ) + second = save_run_evidence( + cached.compiled, + cached.manifest, + store=fresh.store, + directory=tmp_path / "attempt-2", + attempt_id="attempt-2", + phase="final", + ) + published = publish_run_evidence(second, out) + index = json.loads(published.read_bytes()) + assert [(run["attempt_id"], run["phase"]) for run in index["runs"]] == [ + ("attempt-1", "numerical"), + ("attempt-2", "final"), + ] + assert all( + Path(run[field]["path"]).parent == Path(".") + for run in index["runs"] + for field in ("graph", "manifest", "binding", "summaries") + ) + runs = load_run_evidence(published, store=fresh.store) + overlay = collect_execution_evidence( + graph_schema(fresh.compiled), runs=runs, store=fresh.store + ) + assert [phase["operations"]["survey"]["cache"] for phase in overlay["phases"]] == [ + "miss", + "hit", + ] + document = orrery_document(fresh.compiled, execution=overlay) + survey = next(node for node in document["nodes"] if node["label"] == "survey") + assert survey["statuses"][1]["label"] == "Computed: numerical · reused 1×" + # Republishing the same attempt replaces its entry instead of duplicating it. + publish_run_evidence(second, out) + assert len(json.loads(published.read_bytes())["runs"]) == 2 + damaged = out / index["runs"][0]["graph"]["path"] + damaged.write_bytes(damaged.read_bytes() + b" ") + with pytest.raises(ValueError, match="no longer matches"): + publish_run_evidence(second, out) + damaged.unlink() + with pytest.raises(ValueError, match="is missing"): + publish_run_evidence(second, out) + + +def test_checkpoint_references_link_only_verifiable_bytes(tmp_path): + graph = tmp_path / "graph.json" + manifest = tmp_path / "manifest.json" + evidence = tmp_path / "execution.evidence.json" + for path in (graph, manifest, evidence): + path.write_bytes(b'{"recorded":true}') + record = { + "graph_declaration": { + "path": "graph.json", + "sha256": sha256(graph.read_bytes()), + }, + "graph_manifest": { + "path": str(manifest), + "sha256": sha256(manifest.read_bytes()), + }, + "graph_execution_evidence": { + "path": str(evidence), + "sha256": sha256(evidence.read_bytes()), + }, + } + refs, missing = checkpoint_references(record, base=tmp_path) + assert [ref["label"] for ref in refs] == [ + "Producing graph declaration", + "Producing run manifest", + "Producing execution evidence index", + ] + assert all(Path(ref["url"]).is_absolute() for ref in refs) + assert missing is None + manifest.unlink() + refs, missing = checkpoint_references(record, base=tmp_path) + assert [ref["label"] for ref in refs] == [ + "Producing graph declaration", + "Producing execution evidence index", + ] + assert "Producing run manifest bytes unavailable" in missing + assert record["graph_manifest"]["sha256"] in missing + refs, missing = checkpoint_references({}, base=tmp_path) + assert refs == [] and "Producing graph declaration" in missing + graph.write_bytes(b'{"recorded":false}') + with pytest.raises(ValueError, match="digest mismatch"): + checkpoint_references(record, base=tmp_path) + + +def test_save_graph_schema_copies_upstream_bytes_and_relinks(fresh, tmp_path): + from microcosm.graph.presentation import PRESENTATION_EXTENSION + + upstream = tmp_path / "spine.graph.json" + upstream.write_bytes(b'{"spine":true}') + contract = presentation(fresh.compiled) + contract["scope"]["boundaries"] = [ + { + "operation": "survey", + "kind": "checkpoint", + "upstream": [ + { + "label": "Producing graph declaration", + "url": str(upstream), + "sha256": sha256(upstream.read_bytes()), + }, + {"label": "Published method", "url": "https://example.org/method"}, + ], + } + ] + saved = save_graph_schema(fresh.compiled, tmp_path / "out", presentation=contract) + schema = json.loads(saved.read_bytes()) + refs = schema["extensions"][PRESENTATION_EXTENSION]["scope"]["boundaries"][0][ + "upstream" + ] + assert refs[0]["url"] == f"upstream-{refs[0]['sha256']}.json" + assert (saved.parent / refs[0]["url"]).read_bytes() == upstream.read_bytes() + assert refs[1]["url"] == "https://example.org/method" + assert canonical_json(schema) == saved.read_bytes() + upstream.write_bytes(b'{"spine":false}') + with pytest.raises(ValueError, match="changed during capture"): + save_graph_schema(fresh.compiled, tmp_path / "again", presentation=contract) diff --git a/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery.py b/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery.py index 88ac4d0cf..a62391dec 100644 --- a/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery.py +++ b/packages/microcosm-graph/tests/engine_free/shared/test_graph_orrery.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +import hashlib import json import os import subprocess @@ -43,7 +44,20 @@ def test_document_is_complete_static_metadata_with_no_runtime_claims(): document = orrery_document_from_schema(schema) assert document["schemaVersion"] == "graph-explorer/v1" - assert document["metadata"]["microcosm"] == schema + summary = document["metadata"]["microcosm"] + assert summary["protocol"] == schema["protocol"] + assert summary["graph_sha256"] == schema["graph_sha256"] + assert ( + summary["schema_sha256"] == hashlib.sha256(canonical_json(schema)).hexdigest() + ) + assert summary["counts"] == { + "operations": 6, + "sources": 1, + "fields": len(schema["fields"]), + "input_bindings": len(schema["input_bindings"]), + } + assert summary["compiled_order"] == schema["compiled"]["order"] + assert "graph" not in summary and "fields" not in summary assert document["metadata"]["scope"] == "complete_compiler_schema" assert document["metadata"]["truncated"] is False assert len([node for node in document["nodes"] if node["kind"] == "operation"]) == 6 @@ -66,6 +80,13 @@ def test_document_is_complete_static_metadata_with_no_runtime_claims(): assert {edge["source"] for edge in document["edges"]} | { edge["target"] for edge in document["edges"] } <= identities + fields = [node for node in document["nodes"] if node["kind"] == "field"] + assert fields + for field in fields: + assert _parts(field["parentId"]) == ["operation", field["data"]["provider"]] + assert field["data"]["population"] in field["description"] + read = next(edge for edge in document["edges"] if edge["kind"] == "declared_read") + assert read["label"].startswith(read["data"]["read_kind"].replace("_", " ")) def test_descriptions_are_promoted_and_declarations_remain_available(): @@ -197,7 +218,7 @@ def test_json_is_deterministic_detached_and_transports_large_numbers(): "large": {"integer_literal": str(2**80)}, "value": {"float_literal": "1e+100"}, } - document["metadata"]["microcosm"]["graph"]["nodes"].clear() + document["metadata"]["microcosm"]["extensions"].clear() assert schema == before @@ -251,11 +272,42 @@ def test_output_complexity_and_size_limits_are_enforced(monkeypatch): orrery_document_from_schema(schema) -def test_schema_embeds_the_exact_canonical_graph_declaration(): +def test_schema_digest_binds_the_separately_saved_canonical_schema(tmp_path): + from microcosm.graph import save_graph_schema + + compiled = compiled_graph() + saved = save_graph_schema(compiled, tmp_path) + document = orrery_document_from_schema(json.loads(saved.read_bytes())) + summary = document["metadata"]["microcosm"] + assert summary["schema_sha256"] == hashlib.sha256(saved.read_bytes()).hexdigest() + assert ( + summary["graph_sha256"] + == hashlib.sha256(graph_to_json(compiled.graph).encode()).hexdigest() + ) + + +def test_presentation_scope_names_the_document(): + from microcosm.graph.presentation import ( + PRESENTATION_EXTENSION, + PRESENTATION_PROTOCOL, + ) + compiled = compiled_graph() - document = orrery_document_from_schema(graph_schema(compiled)) - embedded = document["metadata"]["microcosm"]["graph"] - assert canonical_json(embedded).decode() == graph_to_json(compiled.graph) + plain = orrery_document(compiled) + assert _parts(plain["id"]) == ["microcosm", "invented"] + assert plain["title"] == "INVENTED graph schema" + scoped = orrery_document( + compiled, + extensions={ + PRESENTATION_EXTENSION: { + "protocol": PRESENTATION_PROTOCOL, + "scope": {"id": "national", "label": "National build"}, + } + }, + ) + assert _parts(scoped["id"]) == ["microcosm", "invented", "national"] + assert scoped["title"] == "INVENTED · National build" + assert orrery_document(compiled, title="Given")["title"] == "Given" def test_direct_graph_compiled_and_saved_schema_paths_are_identical(): diff --git a/tools/orrery-contract/verify-execution.mjs b/tools/orrery-contract/verify-execution.mjs index fd0743bf1..cdb959a59 100644 --- a/tools/orrery-contract/verify-execution.mjs +++ b/tools/orrery-contract/verify-execution.mjs @@ -4,11 +4,21 @@ import { parseGraphDocument } from '@axiom-foundation/orrery'; const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); const document = parseGraphDocument(JSON.parse(Buffer.concat(chunks).toString('utf8'))); +const labels = node => (node.statuses ?? []).map(status => status.label); +const operations = document.nodes.filter(node => node.kind === 'operation'); assert.ok(document.nodes.some(node => node.kind === 'group')); -assert.ok(document.nodes.some(node => node.parentId)); +assert.ok(operations.every(node => node.parentId)); +assert.ok(document.nodes.filter(node => node.kind === 'field').every(node => node.parentId?.startsWith('["operation"'))); assert.ok(document.nodes.some(node => node.sources?.length)); -assert.ok(document.nodes.some(node => node.statuses?.length === 3)); +assert.ok(operations.every(node => labels(node).some(label => label.startsWith('Execution: ')))); +assert.ok(operations.every(node => labels(node).some(label => /^(Computed|Reused|Cache): /.test(label)))); +assert.ok(operations.some(node => labels(node)[0].startsWith('Gate: '))); +assert.ok(!operations.some(node => labels(node).includes('Gate: not_applicable'))); assert.ok(document.activities.length); +assert.ok(document.activities.every(activity => activity.agent?.name)); assert.ok(document.artifacts.length); +assert.ok(document.artifacts.every(artifact => /\(\w+, \d+ files?\)$/.test(artifact.label))); assert.ok(!document.receipts?.length); +assert.equal(typeof document.metadata.microcosm.schema_sha256, 'string'); +assert.ok(!('graph' in document.metadata.microcosm)); process.stdout.write('Orrery accepted grouping and recorded execution.\n'); From f420f109aeb006a5a1694bf0385b57c6f3f62a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:59:58 +0100 Subject: [PATCH 5/7] Address review: diagnostic capture, relative checkpoint links, bounds - Update the UK spine export test to the digest-bound schema summary (the engine-free lane stopped there). - Make evidence capture inside a build diagnostic: a summary provider that raises is recorded as provider_failed with its error beside the artifact binding and the build continues. The explicit export command still runs providers strictly. - Record the spine checkpoint's graph, manifest, schema and evidence links relative to the sidecar, so a checkpoint moved with its evidence directory keeps resolvable links and no machine-local path is stored. - Shape-check composite metadata in the presentation contract. - Write copied checkpoint bytes and published evidence files atomically. - Drop the unreferenced spine.graph-declaration.json write. - Document that execution exports from licensed runs need the same output clearance as the diagnostics they summarise, say that the serialized string bound applies to every string, and reword the weight-summary docstring. Co-Authored-By: Claude Fable 5.1 --- .../1144-uk-full-graph-export.fixed.md | 2 +- docs/orrery-adapter.md | 17 ++++- .../microcosm/build/uk_runtime/spine_build.py | 20 +++-- .../tests/engine_free/uk/test_uk_frs_spine.py | 5 +- .../uk/test_uk_full_calibration_graph.py | 61 +++++++++++++++ .../tests/engine_free/uk/test_uk_graph.py | 12 ++- .../uk/test_uk_staging_integration.py | 4 +- .../src/microcosm/calibrate/graph_evidence.py | 6 +- .../src/microcosm/graph/evidence.py | 75 +++++++++++++------ .../src/microcosm/graph/orrery_evidence.py | 10 ++- .../src/microcosm/graph/presentation.py | 20 +++++ .../engine_free/shared/test_graph_evidence.py | 16 ++++ 12 files changed, 208 insertions(+), 40 deletions(-) diff --git a/changelog.d/1144-uk-full-graph-export.fixed.md b/changelog.d/1144-uk-full-graph-export.fixed.md index a5a7ca105..44308df1f 100644 --- a/changelog.d/1144-uk-full-graph-export.fixed.md +++ b/changelog.d/1144-uk-full-graph-export.fixed.md @@ -1 +1 @@ -Allow the maintained full UK gate contracts to round-trip through compiler schema and Orrery export, while retaining bounded strings and document sizes. +Raise the bound on every serialized string in compiler schemas and Orrery exports to 131,072 characters so the maintained full UK gate contracts round-trip; the document, complexity, node and edge limits are unchanged. diff --git a/docs/orrery-adapter.md b/docs/orrery-adapter.md index 1385b7bda..7f1f8f7ae 100644 --- a/docs/orrery-adapter.md +++ b/docs/orrery-adapter.md @@ -186,7 +186,16 @@ lookup occurs in the shared exporter. Native summaries are captured once per artifact and reused across later phases. Providers are trusted projections: they must emit aggregates and omit -record-level values. +record-level values. During a build, evidence capture is diagnostic: a provider +that raises is recorded as `provider_failed` with its error beside the artifact +binding and the build continues; the explicit export command runs providers +strictly and refuses on a failure. + +An execution export from a licensed run carries the achieved value of every +target, local-area targets included, and the weight distribution's extremes and +quantiles. Inside the private build bundle that is fine; before an execution +snapshot or its HTML is posted anywhere public it needs the same output +clearance as the calibration diagnostics it summarises. `save_run_evidence` appends an `execution.evidence.json` index with protocol `microcosm.graph.execution-input.v1`, plus exact graph, manifest, binding and @@ -322,9 +331,9 @@ graph.orrery.html#collapsedIds=["[\"group\",\"enrichment\"]"] Fields remain searchable and inspectable from the index, and expanding one operation shows exactly its versioned fields because fields are contained by -their provider. Serialized operation contracts can use strings up to 131,072 -characters; the overall document, complexity, node, and edge limits remain -unchanged. These are declaration and synthetic diagnostic measurements, not a +their provider. Every serialized string in the compiler schema and the export, +operation contracts included, may be up to 131,072 characters; the overall +document, complexity, node, and edge limits remain unchanged. These are declaration and synthetic diagnostic measurements, not a licensed population run. ## Fields and input bindings diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py index 97dc9c518..ba7aa9afd 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py @@ -5,6 +5,7 @@ import argparse import hashlib import json +import os import sys import time import uuid @@ -180,7 +181,6 @@ from microcosm.graph import ( ContentStore, compile_graph, - graph_to_json, run_graph, save_run_evidence, ) @@ -1908,8 +1908,6 @@ def main(argv: list[str] | None = None) -> int: _population_observer_detach=False, ) graph_manifest.save(checkpoint_root / "spine.graph.json") - graph_declaration_path = checkpoint_root / "spine.graph-declaration.json" - graph_declaration_path.write_text(graph_to_json(graph), encoding="utf-8") save_uk_graph_schema(graph, checkpoint_root, scope="raw_spine", spec=spec) graph_evidence_path = save_run_evidence( compiled_graph, @@ -2044,21 +2042,29 @@ def main(argv: list[str] | None = None) -> int: ), ) sidecar["operation_inventory"] = list(uk_spine_operation_inventory(graph, spec)) + + # Relative to the sidecar, so a checkpoint moved with its evidence + # directory keeps resolvable links and records no machine-local paths. + def _checkpoint_link(path: Path) -> str: + return Path( + os.path.relpath(path.resolve(), sidecar_path.parent.resolve()) + ).as_posix() + sidecar["graph_manifest"] = { - "path": str(graph_manifest_path.resolve()), + "path": _checkpoint_link(graph_manifest_path), "key": graph_manifest.key, "sha256": hashlib.sha256(graph_manifest_path.read_bytes()).hexdigest(), } sidecar["graph_declaration"] = { - "path": str(graph_declaration_path.resolve()), + "path": _checkpoint_link(graph_declaration_path), "sha256": hashlib.sha256(graph_declaration_path.read_bytes()).hexdigest(), } sidecar["graph_schema"] = { - "path": str(graph_schema_path.resolve()), + "path": _checkpoint_link(graph_schema_path), "sha256": hashlib.sha256(graph_schema_path.read_bytes()).hexdigest(), } sidecar["graph_execution_evidence"] = { - "path": str(graph_evidence_path.resolve()), + "path": _checkpoint_link(graph_evidence_path), "sha256": hashlib.sha256(graph_evidence_path.read_bytes()).hexdigest(), } stage_evidence = stored_evidence["stage_evidence"] diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_spine.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_spine.py index 678b037bd..e3ba79e4e 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_spine.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_frs_spine.py @@ -1811,7 +1811,8 @@ def test_driver_writes_spine_h5_sidecars_and_logbook( "graph_execution_evidence", ): reference = sidecar[name] - evidence_path = Path(reference["path"]) + assert not Path(reference["path"]).is_absolute() + evidence_path = output.parent / reference["path"] assert "graph-evidence" in evidence_path.parts assert ( hashlib.sha256(evidence_path.read_bytes()).hexdigest() @@ -1819,7 +1820,7 @@ def test_driver_writes_spine_h5_sidecars_and_logbook( ) from microcosm.graph import ContentStore, load_run_evidence - index_path = Path(sidecar["graph_execution_evidence"]["path"]) + index_path = output.parent / sidecar["graph_execution_evidence"]["path"] runs = load_run_evidence( index_path, store=ContentStore(index_path.parents[2] / "node-graph", create=False), diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py index e1c7f5196..a5d49c4a4 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_full_calibration_graph.py @@ -668,3 +668,64 @@ def spy(*args, **kwargs): frame, dense, selection=warm, draw=draw, initial_lambda=1e-3, **options ) assert refit.receipt["selection_l0_lambda"] == warm.selection.l0_lambda + + +def test_summary_provider_failure_is_diagnostic_in_capture_and_strict_at_export( + tmp_path, +): + from microcosm.calibrate.artifacts import PROBLEM_TYPE + from microcosm.graph import ( + collect_execution_evidence, + graph_schema, + load_run_evidence, + orrery_document, + record_run_binding, + save_run_evidence, + ) + + graph, endpoints = compiled(None) + fixture = tmp_path / "fixture" + fixture.write_bytes(b"fixture") + store = ContentStore(tmp_path / "store") + manifest = run_graph( + graph, sources={"fixture": fixture}, store=store, kernels=registry() + ) + + def failing(context): + raise RuntimeError("provider refused the artifact") + + providers = {(PROBLEM_TYPE.name, PROBLEM_TYPE.schema_version): failing} + # Capture after a successful solve records the failure and continues. + index = save_run_evidence( + graph, + manifest, + store=store, + directory=tmp_path / "evidence", + attempt_id="fixture", + phase="numerical", + artifact_summaries=providers, + ) + runs = load_run_evidence(index, store=store) + evidence = collect_execution_evidence(graph_schema(graph), runs=runs, store=store) + problem = next( + item + for item in evidence["summaries"] + if item["node"] == "pool" and item["artifact"] == "problem" + ) + assert problem["data"] == {} + assert problem["error"] == "RuntimeError: provider refused the artifact" + status = evidence["phases"][0]["operations"]["pool"]["artifact_summary_status"] + assert status["problem"] == "provider_failed" + document = orrery_document(graph, execution=evidence) + pool = next( + node + for node in document["nodes"] + if json.loads(node["id"]) == ["operation", "pool"] + ) + assert pool["data"]["artifact_summaries"]["problem"]["status"] == "provider_failed" + # The explicit export runs providers strictly. + run = record_run_binding(graph, manifest, attempt_id="fixture", phase="numerical") + with pytest.raises(RuntimeError, match="provider refused"): + collect_execution_evidence( + graph_schema(graph), runs=[run], store=store, artifact_summaries=providers + ) diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_graph.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_graph.py index 76949264c..55a0c40b6 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_graph.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_graph.py @@ -1,7 +1,10 @@ """Tests split from packages/microcosm-build/tests/test_uk_graph.py.""" # ruff: noqa: F403, F405 +import hashlib + from microcosm.graph import graph_schema, orrery_document +from microcosm.graph.canonical import canonical_json from test_support.microcosm_build.uk_graph import * @@ -272,7 +275,14 @@ def test_uk_spine_exports_complete_orrery_document() -> None: assert materialized_claims assert actual_reads == expected_reads assert document["schemaVersion"] == "graph-explorer/v1" - assert document["metadata"]["microcosm"] == schema + summary = document["metadata"]["microcosm"] + assert summary["graph_sha256"] == schema["graph_sha256"] + assert ( + summary["schema_sha256"] == hashlib.sha256(canonical_json(schema)).hexdigest() + ) + assert summary["counts"]["operations"] == len(schema["graph"]["nodes"]) + assert summary["counts"]["fields"] == len(schema["fields"]) + assert "graph" not in summary assert document["metadata"]["truncated"] is False diff --git a/packages/microcosm-build/tests/integration/uk/test_uk_staging_integration.py b/packages/microcosm-build/tests/integration/uk/test_uk_staging_integration.py index 520632176..cfd4ae2cf 100644 --- a/packages/microcosm-build/tests/integration/uk/test_uk_staging_integration.py +++ b/packages/microcosm-build/tests/integration/uk/test_uk_staging_integration.py @@ -86,12 +86,12 @@ def test_uk_staging_smoke_command_runs_every_spine_stage(tmp_path: Path) -> None ) assert len(sidecar["synthetic_fixture"]["digest"]) == 64 - schema = json.loads(Path(sidecar["graph_schema"]["path"]).read_bytes()) + schema = json.loads((output.parent / sidecar["graph_schema"]["path"]).read_bytes()) store = ContentStore( tmp_path / ".uk-smoke.checkpoints" / "node-graph", create=False ) runs = load_run_evidence( - Path(sidecar["graph_execution_evidence"]["path"]), store=store + output.parent / sidecar["graph_execution_evidence"]["path"], store=store ) document = orrery_document_from_schema( schema, execution=collect_execution_evidence(schema, runs=runs, store=store) diff --git a/packages/microcosm-calibrate/src/microcosm/calibrate/graph_evidence.py b/packages/microcosm-calibrate/src/microcosm/calibrate/graph_evidence.py index c757740e9..c3324fc52 100644 --- a/packages/microcosm-calibrate/src/microcosm/calibrate/graph_evidence.py +++ b/packages/microcosm-calibrate/src/microcosm/calibrate/graph_evidence.py @@ -104,7 +104,11 @@ def solution_summary(context: ArtifactSummaryContext) -> dict: def result_summary_data(result, ordered) -> dict: - """Project recorded diagnostics, never record axes or individual weights.""" + """Project recorded diagnostics without entity axes or the weight vector. + + The weight summary is distributional: its extremes and quantiles are + individual weight values, not record identities. + """ rows = [] for index, diagnostic in enumerate(result.diagnostics): target = ordered.problem.targets[index] diff --git a/packages/microcosm-graph/src/microcosm/graph/evidence.py b/packages/microcosm-graph/src/microcosm/graph/evidence.py index e2ecc7845..bbba7501c 100644 --- a/packages/microcosm-graph/src/microcosm/graph/evidence.py +++ b/packages/microcosm-graph/src/microcosm/graph/evidence.py @@ -9,6 +9,7 @@ import hashlib import json +import logging import os import stat import uuid @@ -52,6 +53,8 @@ EVIDENCE_INPUT_PROTOCOL = "microcosm.graph.execution-input.v1" EXECUTION_PROTOCOL = "microcosm.graph.execution-evidence.v1" _MAX_BYTES = 32 * 1024 * 1024 +_SUMMARY_FIELDS = frozenset({"node", "artifact", "key", "type", "data"}) +_LOG = logging.getLogger(__name__) def sha256(payload: bytes) -> str: @@ -312,7 +315,15 @@ def _summaries( registry: ArtifactSummaryRegistry, *, cached: Mapping[str, dict] | None = None, + strict: bool = True, ) -> tuple[dict, ...]: + """Run the registered providers; with ``strict=False`` a failure is recorded. + + Evidence capture inside a build is diagnostic: a provider that raises after + a successful solve must not abort the build, so its error is kept beside + the artifact binding and the summary status becomes ``provider_failed``. + The explicit export command runs providers strictly. + """ summaries = [] for node_id, receipt in run.manifest.nodes.items(): for name, descriptor in receipt.typed_artifacts.get("outputs", {}).items(): @@ -325,23 +336,36 @@ def _summaries( if cached is not None and key in cached: summaries.append(cached[key]) continue - data = _plain( - provider( - ArtifactSummaryContext( - run, node_id, name, store.load_bytes(key), store + record = { + "node": node_id, + "artifact": name, + "key": key, + "type": dict(artifact_type), + } + try: + data = _plain( + provider( + ArtifactSummaryContext( + run, node_id, name, store.load_bytes(key), store + ) ) ) - ) - require(type(data) is dict, "artifact summary must be an object") - summaries.append( - { - "node": node_id, - "artifact": name, - "key": key, - "type": dict(artifact_type), - "data": data, - } - ) + require(type(data) is dict, "artifact summary must be an object") + except Exception as error: + if strict: + raise + _LOG.warning( + "Artifact summary for %s.%s failed and was recorded: %s: %s", + node_id, + name, + type(error).__name__, + error, + ) + record["data"] = {} + record["error"] = f"{type(error).__name__}: {error}" + else: + record["data"] = data + summaries.append(record) return tuple(summaries) @@ -398,12 +422,14 @@ def collect_execution_evidence( for summary in run.summaries: require( type(summary) is dict - and set(summary) == {"node", "artifact", "key", "type", "data"}, + and _SUMMARY_FIELDS <= set(summary) <= _SUMMARY_FIELDS | {"error"}, "recorded summary fields", ) text(summary["node"], "summary node") text(summary["artifact"], "summary artifact") digest(summary["key"]) + if "error" in summary: + text(summary["error"], "summary error") supplied = {(item["node"], item["artifact"]): item for item in run.summaries} supplied.update({(item["node"], item["artifact"]): item for item in summaries}) summaries = tuple(supplied.values()) @@ -492,6 +518,8 @@ def collect_execution_evidence( "artifact_summary_status": { name: "unavailable" if name not in receipt.opaque_artifacts + else "provider_failed" + if "error" in supplied.get((node_id, name), {}) else "present" if (node_id, name) in supplied else "provider_not_configured" @@ -529,8 +557,7 @@ def collect_execution_evidence( ) -def _atomic_json(path: Path, value: object) -> None: - payload = canonical_json(value) +def _atomic_bytes(path: Path, payload: bytes) -> None: require(len(payload) <= _MAX_BYTES, "native evidence byte limit") path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") @@ -541,6 +568,10 @@ def _atomic_json(path: Path, value: object) -> None: temporary.unlink(missing_ok=True) +def _atomic_json(path: Path, value: object) -> None: + _atomic_bytes(path, canonical_json(value)) + + def save_run_evidence( compiled: CompiledGraph, manifest: RunManifest, @@ -585,7 +616,9 @@ def save_run_evidence( ) for item in values: cached[item["key"]] = item - summary_records = _summaries(run, store, artifact_summaries or {}, cached=cached) + summary_records = _summaries( + run, store, artifact_summaries or {}, cached=cached, strict=False + ) entry = {"attempt_id": attempt_id, "phase": phase} for name, value in ( ("graph", json.loads(graph_to_json(compiled.graph))), @@ -763,7 +796,7 @@ def save_graph_schema( "checkpoint reference changed during capture", ) relative = Path(f"upstream-{ref['sha256']}.json") - (directory / relative).write_bytes(payload) + _atomic_bytes(directory / relative, payload) ref["url"] = relative.as_posix() extensions = {PRESENTATION_EXTENSION: contract} compiled = compile_graph(graph) if isinstance(graph, Graph) else graph @@ -852,7 +885,7 @@ def publish_run_evidence( sha256(payload) == digest(entry[field]["sha256"]), "native phase evidence changed during capture", ) - (out_dir / filename).write_bytes(payload) + _atomic_bytes(out_dir / filename, payload) published_entry[field] = { "path": filename, "sha256": entry[field]["sha256"], diff --git a/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py b/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py index 9459c779a..79670dbaf 100644 --- a/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py +++ b/packages/microcosm-graph/src/microcosm/graph/orrery_evidence.py @@ -226,7 +226,15 @@ def apply_execution(document: dict, schema: dict, execution: object) -> None: ] previews = {} for item in summaries[node_id]: - data = summary_catalog[item["key"]]["data"] + entry = summary_catalog[item["key"]] + if "error" in entry: + previews[item["artifact"]] = { + "key": item["key"], + "status": "provider_failed", + "error": text(entry["error"], "summary error"), + } + continue + data = entry["data"] preview = { key: value for key, value in data.items() if key != "tables" } diff --git a/packages/microcosm-graph/src/microcosm/graph/presentation.py b/packages/microcosm-graph/src/microcosm/graph/presentation.py index cc473cbe1..911a7a867 100644 --- a/packages/microcosm-graph/src/microcosm/graph/presentation.py +++ b/packages/microcosm-graph/src/microcosm/graph/presentation.py @@ -117,6 +117,26 @@ def validate_presentation(schema: dict) -> dict | None: for field in ("label", "description"): if field in record: text(record[field], field) + if "composite" in record: + composite = record["composite"] + require(type(composite) is dict, "composite must be an object") + unit = composite.get("execution_unit") + require( + unit is None or unit in {"single", "composite"}, + "composite execution_unit", + ) + members = composite.get("operations", []) + require( + type(members) is list + and all( + type(item) is dict and type(item.get("kind")) is str + for item in members + ), + "composite operations", + ) + for field in ("stage", "node", "kernel", "coupling", "randomness"): + if composite.get(field) is not None: + text(composite[field], f"composite {field}") refs = record.get("references", []) require(type(refs) is list, "references must be an array") for ref in refs: diff --git a/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py b/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py index 360be2468..356a760f3 100644 --- a/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py +++ b/packages/microcosm-graph/tests/engine_free/shared/test_graph_evidence.py @@ -620,3 +620,19 @@ def test_save_graph_schema_copies_upstream_bytes_and_relinks(fresh, tmp_path): upstream.write_bytes(b'{"spine":false}') with pytest.raises(ValueError, match="changed during capture"): save_graph_schema(fresh.compiled, tmp_path / "again", presentation=contract) + + +@pytest.mark.parametrize( + "composite", + [ + "single", + {"execution_unit": "paired"}, + {"operations": [{"parameters": {}}]}, + {"coupling": 7}, + ], +) +def test_malformed_composite_metadata_is_refused(fresh, composite): + contract = presentation(fresh.compiled) + contract["operations"]["survey"]["composite"] = composite + with pytest.raises(ValueError, match="composite"): + orrery_document(fresh.compiled, extensions={PRESENTATION_EXTENSION: contract}) From 0bee8bfb6026d8ec091bf566c9c8bb6d4772ec8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:23:46 +0100 Subject: [PATCH 6/7] Record evidence-capture cost per phase Append the wall time and peak RSS of each phase's evidence capture to evidence-capture-timing.jsonl beside the build (and the spine checkpoint), so a real rung reports what the summary providers cost. Co-Authored-By: Claude Fable 5.1 --- .../build/uk_runtime/full_build_cli.py | 27 +++++++++++++++++++ .../microcosm/build/uk_runtime/spine_build.py | 20 ++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/full_build_cli.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/full_build_cli.py index 83519973e..4deb97411 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/full_build_cli.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/full_build_cli.py @@ -969,13 +969,40 @@ def _materialize_evidence(manifest, store, out: Path) -> dict: return inventory +def _record_capture_cost( + out: Path, phase: str, started: float, rss_before: int +) -> None: + """Append the evidence-capture cost of one phase; a measurement, not a gate.""" + import resource + + record = { + "phase": phase, + "wall_s": round(time.perf_counter() - started, 3), + "maxrss_before_bytes": rss_before, + "maxrss_after_bytes": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, + } + with (out / "evidence-capture-timing.jsonl").open("a", encoding="utf-8") as sink: + sink.write(json.dumps(record, sort_keys=True) + "\n") + print( + f"evidence capture {phase}: {record['wall_s']:.1f}s, " + f"peak RSS {record['maxrss_after_bytes'] / 2**30:.2f} GiB", + file=sys.stderr, + flush=True, + ) + + def _persist_checkpoint(manifest, store, args, phase: str, *, graph: Graph) -> None: """Keep receipts and small evidence durable if a later kernel refuses.""" + import resource + payload = manifest.to_json_bytes() materialize_bytes(payload, args.out / f"{phase}.graph.json") attempt = Path(args.attempt_evidence) materialize_bytes(payload, attempt / f"{phase}.graph.json") + started = time.perf_counter() + rss_before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss persist_uk_run_evidence(graph, manifest, store, args, phase=phase) + _record_capture_cost(args.out, phase, started, rss_before) evidence = {} for node_id, receipt in manifest.nodes.items(): for name, key in receipt.opaque_artifacts.items(): diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py index ba7aa9afd..49a78cfe3 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/spine_build.py @@ -1909,6 +1909,10 @@ def main(argv: list[str] | None = None) -> int: ) graph_manifest.save(checkpoint_root / "spine.graph.json") save_uk_graph_schema(graph, checkpoint_root, scope="raw_spine", spec=spec) + import resource + + capture_started = time.perf_counter() + capture_rss_before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss graph_evidence_path = save_run_evidence( compiled_graph, graph_manifest, @@ -1920,6 +1924,22 @@ def main(argv: list[str] | None = None) -> int: phase="spine", artifact_summaries=UK_SUMMARY_PROVIDERS, ) + capture_record = { + "phase": "spine", + "wall_s": round(time.perf_counter() - capture_started, 3), + "maxrss_before_bytes": capture_rss_before, + "maxrss_after_bytes": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, + } + with (checkpoint_root / "evidence-capture-timing.jsonl").open( + "a", encoding="utf-8" + ) as sink: + sink.write(json.dumps(capture_record, sort_keys=True) + "\n") + print( + f"evidence capture spine: {capture_record['wall_s']:.1f}s, peak RSS " + f"{capture_record['maxrss_after_bytes'] / 2**30:.2f} GiB", + file=sys.stderr, + flush=True, + ) graph_run_reference = json.loads(graph_evidence_path.read_text())["runs"][-1] graph_declaration_path = ( graph_evidence_path.parent / graph_run_reference["graph"]["path"] From 5fa47f4220f6dcce639b9142dee4d88d2a4bbe48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:29:38 +0100 Subject: [PATCH 7/7] Keep the spool adoption tests inside the retention window on any date The two pre-Alembic spool tests stored rows dated 2 October 2026; once the retention prune's cutoff passed that date, adoption deleted the rows and has_pending() was False. Stamp the rows one hour before the test runs. Co-Authored-By: Claude Fable 5.1 --- .../engine_free/shared/test_telemetry_emitter.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/microcosm-build/tests/engine_free/shared/test_telemetry_emitter.py b/packages/microcosm-build/tests/engine_free/shared/test_telemetry_emitter.py index 46a86c25d..b2308fa4e 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_telemetry_emitter.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_telemetry_emitter.py @@ -4,6 +4,7 @@ import tempfile import threading import time +from datetime import UTC, datetime, timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from types import SimpleNamespace @@ -233,6 +234,12 @@ def test_event_spool_keeps_repeated_run_producers_separate(tmp_path) -> None: assert len(spool.pending_runs()) == 2 +def _recent_timestamp() -> str: + """A stored-row timestamp inside the spool's retention window on any date.""" + + return (datetime.now(UTC) - timedelta(hours=1)).isoformat() + + def test_pre_eligibility_spool_is_not_uploaded_after_upgrade(tmp_path) -> None: path = tmp_path / "events.sqlite3" registration = _registration() @@ -265,7 +272,7 @@ def test_pre_eligibility_spool_is_not_uploaded_after_upgrade(tmp_path) -> None: producer_id="producer-a", registration_json=json.dumps(registration), next_sequence=2, - updated_at="2026-10-02T10:00:00+00:00", + updated_at=_recent_timestamp(), ) ) connection.execute( @@ -275,7 +282,7 @@ def test_pre_eligibility_spool_is_not_uploaded_after_upgrade(tmp_path) -> None: producer_id="producer-a", sequence=1, payload_json=json.dumps({"event_id": "old-event"}), - created_at="2026-10-02T10:00:00+00:00", + created_at=_recent_timestamp(), ) ) engine.dispose() @@ -301,7 +308,7 @@ def test_current_pre_alembic_spool_is_adopted_without_losing_events(tmp_path) -> next_sequence=2, upload_state="pending", local_only_reason=None, - updated_at="2026-10-02T10:00:00+00:00", + updated_at=_recent_timestamp(), ) ) session.add( @@ -311,7 +318,7 @@ def test_current_pre_alembic_spool_is_adopted_without_losing_events(tmp_path) -> producer_id="producer-a", sequence=1, payload={"event_id": "existing-event", "sequence": 1}, - created_at="2026-10-02T10:00:00+00:00", + created_at=_recent_timestamp(), ) ) engine.dispose()