diff --git a/README.md b/README.md index 2a3b54e..09027e0 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ solving remains future work. | Native solver event trace | Implemented as separate opt-in reference/optimized entry points with bounded observed events, full initial state, checkpoints, manifest v3, independent offline replay, and deterministic PNG/GIF views | | Reproducible Z3 summaries | Implemented for fixed seed/thread settings and explicit Boolean/Wang encoding order, result/model, and stable project-owned counts; no internal Z3 trace claim | | Observed-run dossier | Implemented as one opt-in generator of closed raw run metadata, fixed LaTeX, PDF, and reused trace/square/hex assets; four versioned SAT/UNSAT case definitions cover distinct execution shapes | -| Full-pipeline multi-engine capture | Implemented as separate closed v2 case/run contracts and one atomic raw capture over Boolean Z3, one native reduction, reference, optimized, Wang Z3, and existing independent checkers; narrative assets and PDF v2 remain downstream work | +| Full-pipeline multi-engine capture | Implemented as separate closed v2 case/run contracts and one atomic capture over Boolean Z3, one native reduction, reference, optimized, Wang Z3, and existing independent checkers; a closed shared-asset pass now binds semantic milestones and static square/generalized/hex presentations, while PDF v2 remains downstream work | | Native C JSON layer | Not implemented; `src/io/json.c` is a placeholder | | `TaskPlan` and native OpenMP solver | Not implemented; only the build scaffold exists | @@ -234,6 +234,16 @@ The [observed-run dossier contract](docs/run_dossiers.md) defines the four versioned case classes, authoritative raw metadata, fixed TeX scaffolding, asset reuse, atomic output, and UNSAT evidence boundary. +The v2 shared-asset pass is also opt-in through the same dossier command. It +validates the raw multi-engine capture, renders Boolean/Wang encoding order, +Yang--Zhang construction, both observed traces, verification receipts, the +checked witness presentation, and the six-mechanism didactic overview, then +records every GIF, reduced-motion fallback, contact sheet, frame, caption, alt +text, semantic label, owner, and source hash in +`wang-narrative-assets-v1`. The same generator can target a temporary +`canonical-pages` bundle; tests require all compositor-produced bytes to match +the run-specific bundle for identical inputs and parameters. + To export one observed reference run and render selected replay states: ```sh diff --git a/docs/assets/images/builder-routing/contact-sheet.png b/docs/assets/images/builder-routing/contact-sheet.png index 9d94383..905d9d9 100644 Binary files a/docs/assets/images/builder-routing/contact-sheet.png and b/docs/assets/images/builder-routing/contact-sheet.png differ diff --git a/docs/assets/images/builder-routing/frame-00.png b/docs/assets/images/builder-routing/frame-00.png index 03af137..84101a5 100644 Binary files a/docs/assets/images/builder-routing/frame-00.png and b/docs/assets/images/builder-routing/frame-00.png differ diff --git a/docs/assets/images/builder-routing/frame-01.png b/docs/assets/images/builder-routing/frame-01.png index a220f8c..b1121f9 100644 Binary files a/docs/assets/images/builder-routing/frame-01.png and b/docs/assets/images/builder-routing/frame-01.png differ diff --git a/docs/assets/images/builder-routing/frame-02.png b/docs/assets/images/builder-routing/frame-02.png index 3b599b7..83315f9 100644 Binary files a/docs/assets/images/builder-routing/frame-02.png and b/docs/assets/images/builder-routing/frame-02.png differ diff --git a/docs/assets/images/builder-routing/frame-03.png b/docs/assets/images/builder-routing/frame-03.png index b0e1ba5..0e0e1de 100644 Binary files a/docs/assets/images/builder-routing/frame-03.png and b/docs/assets/images/builder-routing/frame-03.png differ diff --git a/docs/assets/images/builder-routing/frame-04.png b/docs/assets/images/builder-routing/frame-04.png index 87a6656..e352835 100644 Binary files a/docs/assets/images/builder-routing/frame-04.png and b/docs/assets/images/builder-routing/frame-04.png differ diff --git a/docs/assets/images/builder-routing/frame-05.png b/docs/assets/images/builder-routing/frame-05.png index ddd6eed..5340377 100644 Binary files a/docs/assets/images/builder-routing/frame-05.png and b/docs/assets/images/builder-routing/frame-05.png differ diff --git a/docs/assets/images/builder-routing/trace.gif b/docs/assets/images/builder-routing/trace.gif index 5f3067a..7319b30 100644 Binary files a/docs/assets/images/builder-routing/trace.gif and b/docs/assets/images/builder-routing/trace.gif differ diff --git a/docs/assets/images/optimized-mechanisms/contact-sheet.png b/docs/assets/images/optimized-mechanisms/contact-sheet.png index 9bf0212..0a9d187 100644 Binary files a/docs/assets/images/optimized-mechanisms/contact-sheet.png and b/docs/assets/images/optimized-mechanisms/contact-sheet.png differ diff --git a/docs/assets/images/optimized-mechanisms/frame-00.png b/docs/assets/images/optimized-mechanisms/frame-00.png index 016ce02..c0d4f16 100644 Binary files a/docs/assets/images/optimized-mechanisms/frame-00.png and b/docs/assets/images/optimized-mechanisms/frame-00.png differ diff --git a/docs/assets/images/optimized-mechanisms/frame-01.png b/docs/assets/images/optimized-mechanisms/frame-01.png index c651553..1ad49a3 100644 Binary files a/docs/assets/images/optimized-mechanisms/frame-01.png and b/docs/assets/images/optimized-mechanisms/frame-01.png differ diff --git a/docs/assets/images/optimized-mechanisms/frame-02.png b/docs/assets/images/optimized-mechanisms/frame-02.png index 6aa6b84..6f4ae6f 100644 Binary files a/docs/assets/images/optimized-mechanisms/frame-02.png and b/docs/assets/images/optimized-mechanisms/frame-02.png differ diff --git a/docs/assets/images/optimized-mechanisms/frame-03.png b/docs/assets/images/optimized-mechanisms/frame-03.png index b5d66f8..ea61b52 100644 Binary files a/docs/assets/images/optimized-mechanisms/frame-03.png and b/docs/assets/images/optimized-mechanisms/frame-03.png differ diff --git a/docs/assets/images/optimized-mechanisms/frame-04.png b/docs/assets/images/optimized-mechanisms/frame-04.png index 325bd02..3e1af5d 100644 Binary files a/docs/assets/images/optimized-mechanisms/frame-04.png and b/docs/assets/images/optimized-mechanisms/frame-04.png differ diff --git a/docs/assets/images/optimized-mechanisms/frame-05.png b/docs/assets/images/optimized-mechanisms/frame-05.png index 32bb622..36aca68 100644 Binary files a/docs/assets/images/optimized-mechanisms/frame-05.png and b/docs/assets/images/optimized-mechanisms/frame-05.png differ diff --git a/docs/assets/images/optimized-mechanisms/frame-06.png b/docs/assets/images/optimized-mechanisms/frame-06.png new file mode 100644 index 0000000..02a2de2 Binary files /dev/null and b/docs/assets/images/optimized-mechanisms/frame-06.png differ diff --git a/docs/assets/images/optimized-mechanisms/trace.gif b/docs/assets/images/optimized-mechanisms/trace.gif index 18f0064..b4e93ad 100644 Binary files a/docs/assets/images/optimized-mechanisms/trace.gif and b/docs/assets/images/optimized-mechanisms/trace.gif differ diff --git a/docs/assets/images/solver-trace/contact-sheet.png b/docs/assets/images/solver-trace/contact-sheet.png index ba28522..425e409 100644 Binary files a/docs/assets/images/solver-trace/contact-sheet.png and b/docs/assets/images/solver-trace/contact-sheet.png differ diff --git a/docs/assets/images/solver-trace/frame-000001.png b/docs/assets/images/solver-trace/frame-000001.png new file mode 100644 index 0000000..21fefb3 Binary files /dev/null and b/docs/assets/images/solver-trace/frame-000001.png differ diff --git a/docs/assets/images/solver-trace/frame-000413.png b/docs/assets/images/solver-trace/frame-000413.png deleted file mode 100644 index f3158e3..0000000 Binary files a/docs/assets/images/solver-trace/frame-000413.png and /dev/null differ diff --git a/docs/assets/images/solver-trace/frame-000827.png b/docs/assets/images/solver-trace/frame-000827.png deleted file mode 100644 index 6e359e2..0000000 Binary files a/docs/assets/images/solver-trace/frame-000827.png and /dev/null differ diff --git a/docs/assets/images/solver-trace/frame-001240.png b/docs/assets/images/solver-trace/frame-001240.png deleted file mode 100644 index 26fb825..0000000 Binary files a/docs/assets/images/solver-trace/frame-001240.png and /dev/null differ diff --git a/docs/assets/images/solver-trace/frame-001654.png b/docs/assets/images/solver-trace/frame-001654.png deleted file mode 100644 index 9322ced..0000000 Binary files a/docs/assets/images/solver-trace/frame-001654.png and /dev/null differ diff --git a/docs/assets/images/solver-trace/frame-002518.png b/docs/assets/images/solver-trace/frame-002518.png new file mode 100644 index 0000000..8b4a6f6 Binary files /dev/null and b/docs/assets/images/solver-trace/frame-002518.png differ diff --git a/docs/assets/images/solver-trace/frame-002893.png b/docs/assets/images/solver-trace/frame-002893.png new file mode 100644 index 0000000..ef8ea34 Binary files /dev/null and b/docs/assets/images/solver-trace/frame-002893.png differ diff --git a/docs/assets/images/solver-trace/frame-002894.png b/docs/assets/images/solver-trace/frame-002894.png new file mode 100644 index 0000000..2b8bb0d Binary files /dev/null and b/docs/assets/images/solver-trace/frame-002894.png differ diff --git a/docs/assets/images/solver-trace/trace.gif b/docs/assets/images/solver-trace/trace.gif index e48e51d..16ec2f6 100644 Binary files a/docs/assets/images/solver-trace/trace.gif and b/docs/assets/images/solver-trace/trace.gif differ diff --git a/docs/assets/images/square-to-hex/contact-sheet.png b/docs/assets/images/square-to-hex/contact-sheet.png index 9d37d82..0ec26e6 100644 Binary files a/docs/assets/images/square-to-hex/contact-sheet.png and b/docs/assets/images/square-to-hex/contact-sheet.png differ diff --git a/docs/assets/images/square-to-hex/frame-00.png b/docs/assets/images/square-to-hex/frame-00.png index e211ed0..cbb9ae0 100644 Binary files a/docs/assets/images/square-to-hex/frame-00.png and b/docs/assets/images/square-to-hex/frame-00.png differ diff --git a/docs/assets/images/square-to-hex/frame-01.png b/docs/assets/images/square-to-hex/frame-01.png index 41d7ef9..798240e 100644 Binary files a/docs/assets/images/square-to-hex/frame-01.png and b/docs/assets/images/square-to-hex/frame-01.png differ diff --git a/docs/assets/images/square-to-hex/frame-02.png b/docs/assets/images/square-to-hex/frame-02.png index b544431..7312ff5 100644 Binary files a/docs/assets/images/square-to-hex/frame-02.png and b/docs/assets/images/square-to-hex/frame-02.png differ diff --git a/docs/assets/images/square-to-hex/frame-03.png b/docs/assets/images/square-to-hex/frame-03.png index a0df31b..36f1917 100644 Binary files a/docs/assets/images/square-to-hex/frame-03.png and b/docs/assets/images/square-to-hex/frame-03.png differ diff --git a/docs/assets/images/square-to-hex/trace.gif b/docs/assets/images/square-to-hex/trace.gif index f8f054d..02824a6 100644 Binary files a/docs/assets/images/square-to-hex/trace.gif and b/docs/assets/images/square-to-hex/trace.gif differ diff --git a/docs/plans/2026-08-31-narrative-migration-checklist.md b/docs/plans/2026-08-31-narrative-migration-checklist.md index 9327972..c5374cb 100644 --- a/docs/plans/2026-08-31-narrative-migration-checklist.md +++ b/docs/plans/2026-08-31-narrative-migration-checklist.md @@ -140,6 +140,7 @@ checks prove the old files are unreferenced before deletion. | `wang-explain-manifest-v3` | `/wang-solver-trace/` | Retain trace-stage meaning | | `wang-run-case-v1` | `/run-dossiers/` | Preserve exact diagnostic case meaning and initial-domain override support | | `wang-run-dossier-v1` | `/run-dossiers/` | Preserve exact single-native-run report meaning and output shape | +| `wang-narrative-assets-v1` | `/run-dossiers/` and `renderer/README.md` | Closed downstream record for fixed component assets, accessibility metadata, source identities, semantic milestones, and static PDF inputs | - [x] Add separate closed v2 case and dossier contracts; do not extend or loosen either v1 schema. @@ -175,11 +176,11 @@ checks prove the old files are unreferenced before deletion. the atomic tileset or standard square/hex output. 2. [x] Capture the explicit v2 multi-engine fields once per engine and bind all component identities by SHA-256. -3. [ ] Produce shared assets through the existing validators, replay, and +3. [x] Produce shared assets through the existing validators, replay, and compositor, including static semantic milestones. -4. [ ] Update the optimized didactic asset to all six accepted mechanisms, +4. [x] Update the optimized didactic asset to all six accepted mechanisms, keeping it secondary to observed trace output. -5. [ ] Generate reduced-motion fallback, contact sheet, caption, alt text, and +5. [x] Generate reduced-motion fallback, contact sheet, caption, alt text, and semantic record for every GIF. 6. [ ] Move public ownership to component pages without duplicating files or explanations; update old pages to links in the same change. diff --git a/docs/run_dossiers.md b/docs/run_dossiers.md index e731a5a..9415840 100644 --- a/docs/run_dossiers.md +++ b/docs/run_dossiers.md @@ -120,7 +120,7 @@ once each. SAT assignments and tilings are checked with the existing pure Python checkers; native tilings also retain the assignment extracted by the existing Yang--Zhang witness bridge. -The raw v2 directory contains `run.json`, the copied CM1-in-3 input, two +The v2 directory contains `run.json`, the copied CM1-in-3 input, two existing trace-v3 manifests, their content-addressed snapshots, and the two existing Z3 summary documents. Both native manifests bind the same formula, tileset, region, and construction-provenance hashes. Agreement means equal @@ -128,11 +128,28 @@ SAT/UNSAT status plus independently valid SAT witnesses; different valid witnesses are not required to be byte-equal. UNKNOWN, mismatch, a truncated trace, or a failed checker aborts the capture before installation. -The raw-capture implementation intentionally produces no v2 PDF or narrative -raster. The closed run contract names the square, generalized, and hex -relationships and leaves their artifact references null for the shared-asset -pass. A later PDF formatter will consume those validated static assets without -solving, checking, replaying, or rendering again. +The downstream shared-asset pass then consumes only that validated capture. +Its closed `wang-narrative-assets-v1` manifest names fixed component assets, +not generic stages: Boolean and Wang Z3 encoding order, canonical region +construction, observed reference and optimized traces, the six checker +records, the checked square/generalized/hex witness sequence, the six retained +optimized mechanisms, and the complete pipeline overview. Every GIF record +contains one reduced-motion PNG, contact sheet, caption, alt text, semantic +label, source identity, completeness/selection scope, and all static frame +hashes. + +Trace frames use `semantic-milestones-v1`, which selects event and phase +transitions before deterministic gap filling; it is distinct from uniform +sampling and reuses the same single replay as PNG, contact-sheet, and GIF +encoding. For SAT, `run.json` binds the three reserved static presentation +artifacts and their hashes. For UNSAT they remain null, while the asset bundle +records an explicit not-applicable panel and never fabricates a witness or +certificate. The separate example is versioned at +`examples/run-cases-v2/pipeline-unsat-search.json`. + +No v2 PDF is produced yet. Its later formatter will consume only the validated +static milestone PNGs already named by the asset manifest, without solving, +checking, replaying, or rendering again. All v2 durations use one monotonic nanosecond clock and are labelled `run-specific-observation-not-a-benchmark`. They are raw facts about that diff --git a/docs/solver_byte_support_2026-08-20.md b/docs/solver_byte_support_2026-08-20.md index 00f958e..ed11f29 100644 --- a/docs/solver_byte_support_2026-08-20.md +++ b/docs/solver_byte_support_2026-08-20.md @@ -14,10 +14,10 @@ nav_order: 60
- - Didactic comparison of the reference solver baseline and the five retained optimized serial mechanisms. + + Didactic comparison of the reference solver baseline and the six retained optimized serial mechanisms. -
Didactic replay. This shared animation locates byte-wise support aggregation among the five isolated mechanisms; the measurements below, not the animation, establish its effect. The static contact sheet shows all stages.
+
Didactic. This shared animation locates byte-wise support aggregation among the six isolated mechanisms, including the later lazy MRV index; the measurements below, not the animation, establish its effect. The static contact sheet shows all stages.
This report evaluates only the union of compatible neighbor tiles during diff --git a/docs/solver_dynamic_stack_2026-08-17.md b/docs/solver_dynamic_stack_2026-08-17.md index f033135..22fa709 100644 --- a/docs/solver_dynamic_stack_2026-08-17.md +++ b/docs/solver_dynamic_stack_2026-08-17.md @@ -14,10 +14,10 @@ nav_order: 30
- - Didactic comparison of the reference solver baseline and the five retained optimized serial mechanisms. + + Didactic comparison of the reference solver baseline and the six retained optimized serial mechanisms. -
Didactic replay. This shared animation locates the dynamic stack among the five isolated mechanisms; the measurements below, not the animation, establish its effect. The static contact sheet shows all stages.
+
Didactic. This shared animation locates the dynamic stack among the six isolated mechanisms, including the later lazy MRV index; the measurements below, not the animation, establish its effect. The static contact sheet shows all stages.
This report evaluates only DFS stack storage. The reference path still diff --git a/docs/solver_initial_trail_2026-08-17.md b/docs/solver_initial_trail_2026-08-17.md index 558b806..6e743c2 100644 --- a/docs/solver_initial_trail_2026-08-17.md +++ b/docs/solver_initial_trail_2026-08-17.md @@ -14,10 +14,10 @@ nav_order: 40
- - Didactic comparison of the reference solver baseline and the five retained optimized serial mechanisms. + + Didactic comparison of the reference solver baseline and the six retained optimized serial mechanisms. -
Didactic replay. This shared animation locates initial-trail omission among the five isolated mechanisms; the measurements below, not the animation, establish its effect. The static contact sheet shows all stages.
+
Didactic. This shared animation locates initial-trail omission among the six isolated mechanisms, including the later lazy MRV index; the measurements below, not the animation, establish its effect. The static contact sheet shows all stages.
This report evaluates undo-trail recording during initial propagation. The diff --git a/docs/solver_queue_dedup_2026-08-20.md b/docs/solver_queue_dedup_2026-08-20.md index 13c9a90..ce0876f 100644 --- a/docs/solver_queue_dedup_2026-08-20.md +++ b/docs/solver_queue_dedup_2026-08-20.md @@ -14,10 +14,10 @@ nav_order: 80
- - Didactic comparison of the reference solver baseline and the five retained optimized serial mechanisms. + + Didactic comparison of the reference solver baseline and the six retained optimized serial mechanisms. -
Didactic replay. This shared animation locates queue deduplication among the five isolated mechanisms; the measurements below, not the animation, establish its effect. The static contact sheet shows all stages.
+
Didactic. This shared animation locates queue deduplication among the six isolated mechanisms, including the later lazy MRV index; the measurements below, not the animation, establish its effect. The static contact sheet shows all stages.
This accepted mechanism changes only the optimized propagation queue. An diff --git a/docs/solver_sat_ownership_2026-08-20.md b/docs/solver_sat_ownership_2026-08-20.md index cb84837..47448ee 100644 --- a/docs/solver_sat_ownership_2026-08-20.md +++ b/docs/solver_sat_ownership_2026-08-20.md @@ -14,10 +14,10 @@ nav_order: 50
- - Didactic comparison of the reference solver baseline and the five retained optimized serial mechanisms. + + Didactic comparison of the reference solver baseline and the six retained optimized serial mechanisms. -
Didactic replay. This shared animation locates SAT ownership transfer among the five isolated mechanisms; the measurements below, not the animation, establish its effect. The static contact sheet shows all stages.
+
Didactic. This shared animation locates SAT ownership transfer among the six isolated mechanisms, including the later lazy MRV index; the measurements below, not the animation, establish its effect. The static contact sheet shows all stages.
This report evaluates only construction of a successful solver result. The diff --git a/docs/wang_square_to_hex.md b/docs/wang_square_to_hex.md index 3e1c09e..9df4b6b 100644 --- a/docs/wang_square_to_hex.md +++ b/docs/wang_square_to_hex.md @@ -37,9 +37,9 @@ construction. They are not attributed to either source's diagram notation.
- Canonical square Wang tile mapped to a pointy-top hex tile while preserving four source edges and adding the fresh kappa color. + Verified square-to-hex transformation preserving four source edges and adding the fresh kappa color. -
Canonical example. The pure renderer-side port and its raster-independent checker produce this mapping; the animation itself is not a proof. The static contact sheet shows all four stages.
+
Verified transformation. The pure renderer-side port and its raster-independent checker produce this mapping; the animation itself is not a proof. The static contact sheet shows all four stages.
## Project convention diff --git a/docs/yang_zhang_builder_design.md b/docs/yang_zhang_builder_design.md index 8309720..fe460b0 100644 --- a/docs/yang_zhang_builder_design.md +++ b/docs/yang_zhang_builder_design.md @@ -47,9 +47,9 @@ transport, and rendering have separate contracts.
- Canonical Yang–Zhang region with native variable, forwarder, crossover, and clause gadget spans appearing in construction order. + Canonical Yang–Zhang construction with native variable, forwarder, crossover, and clause gadget spans appearing in source order. -
Canonical example. The frames reveal spans from the versioned native construction sidecar; they are not timestamps from an instrumented builder. The static contact sheet shows all six stages.
+
Canonical construction. The frames reveal spans from the versioned native construction sidecar; they are not timestamps from an instrumented builder. The static contact sheet shows all six stages.
## 1. Module boundary diff --git a/examples/run-cases-v2/pipeline-unsat-search.json b/examples/run-cases-v2/pipeline-unsat-search.json new file mode 100644 index 0000000..d0c09a3 --- /dev/null +++ b/examples/run-cases-v2/pipeline-unsat-search.json @@ -0,0 +1,18 @@ +{ + "schema": "wang-run-case-v2", + "id": "pipeline-unsat-search-v2", + "title": "Separate UNSAT full-pipeline search capture", + "purpose": "Capture the named engines and observed native search for pipeline_unsat_search.cm13 without presenting it as part of the canonical SAT worked example.", + "source": "tests/instances/pipeline_unsat_search.cm13", + "expected_status": "unsat", + "reference_trace": { + "event_capacity": 8192, + "checkpoint_interval": 128, + "checkpoint_capacity": 64 + }, + "optimized_trace": { + "event_capacity": 8192, + "checkpoint_interval": 128, + "checkpoint_capacity": 64 + } +} diff --git a/python/dossier/multi_engine.py b/python/dossier/multi_engine.py index d6b0432..8e3413c 100644 --- a/python/dossier/multi_engine.py +++ b/python/dossier/multi_engine.py @@ -271,7 +271,7 @@ def generate_multi_engine_dossier( case_path: str | Path, output_directory: str | Path, ) -> Path: - """Capture all named engines once and atomically install the raw v2 dossier.""" + """Capture all named engines once and atomically install the complete v2 dossier.""" case: MultiEngineRunCase = load_run_case_v2(case_path, ROOT) destination = Path(output_directory).resolve() if destination.exists(): @@ -410,8 +410,32 @@ def generate_multi_engine_dossier( timings_ns=timings_ns, artifacts=artifacts, ) - _write_atomic(staging / "run.json", _encode_document(run_document)) - load_run_dossier_v2(staging / "run.json") + run_path = staging / "run.json" + _write_atomic(run_path, _encode_document(run_document)) + load_run_dossier_v2(run_path) + from dossier.narrative_assets import ( + NarrativeAssetError, + attach_narrative_assets, + generate_narrative_assets, + ) + + try: + narrative_manifest = generate_narrative_assets( + run_path, + staging / "assets/narrative", + product="run-specific", + ) + run_document = attach_narrative_assets( + run_document, + staging, + narrative_manifest, + ) + except NarrativeAssetError as error: + raise MultiEngineDossierError( + f"shared narrative asset pass failed: {error}" + ) from error + _write_atomic(run_path, _encode_document(run_document)) + load_run_dossier_v2(run_path) _install_directory(staging, destination) return destination except Exception: diff --git a/python/dossier/narrative_assets.py b/python/dossier/narrative_assets.py new file mode 100644 index 0000000..b560a2a --- /dev/null +++ b/python/dossier/narrative_assets.py @@ -0,0 +1,754 @@ +"""Generate one fixed shared-asset bundle from a validated v2 dossier.""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +RENDERER = ROOT / "renderer" +MECHANISM_SOURCE = RENDERER / "data/optimized-mechanisms-v1.json" + +from formats.narrative_assets import ( + ANIMATION_NAMES, + CANONICAL_PAGES_CASE, + GENERALIZED_SPECIFICATION_SHA256, + IDENTITY_NAMES, + OPTIMIZED_MECHANISMS_SHA256, + SCHEMA_NAME, + STATIC_NAMES, + composite_source_sha256, + generalized_source_sha256, + generalized_presentation_source_sha256, + hex_presentation_source_sha256, + load_narrative_assets, + pipeline_source_sha256, + verification_source_sha256, + witness_presentation_source_sha256, +) +from formats.pipeline_snapshot import _encode_document, _write_atomic +from formats.run_dossier_v2 import validate_run_dossier_v2 +from formats.run_dossier_v2_bundle import load_run_dossier_v2 + + +class NarrativeAssetError(RuntimeError): + """The downstream shared-asset pass failed before atomic installation.""" + + +def _verification_receipts(run: dict[str, object]) -> dict[str, object]: + return { + "schema": "wang-verification-receipts-v1", + "expected_status": run["case"]["expected_status"], + "verification": run["verification"], + "agreement": run["agreement"], + "source_sha256": verification_source_sha256(run), + } + + +def _run_renderer( + arguments: list[str], *, required_outputs: tuple[str, ...] = () +) -> dict[str, Path]: + uv = shutil.which("uv") + if uv is None: + raise NarrativeAssetError("uv is required to run the isolated renderer") + try: + completed = subprocess.run( + [uv, "run", "--locked", "python", *arguments], + cwd=RENDERER, + check=True, + capture_output=True, + text=True, + timeout=180, + ) + except (OSError, subprocess.SubprocessError) as error: + detail = getattr(error, "stderr", "") or getattr(error, "stdout", "") or str(error) + raise NarrativeAssetError(f"narrative renderer failed: {detail.strip()}") from error + outputs: dict[str, Path] = {} + for line in completed.stdout.splitlines(): + if "=" not in line: + continue + name, value = line.split("=", 1) + outputs[name] = Path(value) + missing = tuple(name for name in required_outputs if name not in outputs) + if missing: + raise NarrativeAssetError( + "narrative renderer omitted required output keys: " + ", ".join(missing) + ) + return outputs + + +def _artifact(root: Path, path: Path, media_type: str) -> dict[str, str]: + try: + encoded = path.read_bytes() + relative = path.relative_to(root).as_posix() + except (OSError, ValueError) as error: + raise NarrativeAssetError(f"cannot bind narrative artifact {path!s}: {error}") from error + return { + "path": relative, + "sha256": hashlib.sha256(encoded).hexdigest(), + "media_type": media_type, + } + + +def _metadata( + *, + owner: str, + semantic_label: str, + caption: str, + alt_text: str, + source_contract: str, + source_sha256: str, + producer: str, + validator: str, + compositor: str, +) -> dict[str, str]: + return { + "owner": owner, + "semantic_label": semantic_label, + "caption": caption, + "alt_text": alt_text, + "source_contract": source_contract, + "source_sha256": source_sha256, + "producer": producer, + "validator": validator, + "compositor": compositor, + } + + +def _animation_record( + root: Path, + directory: Path, + fallback: Path, + metadata: dict[str, str], + *, + complete: bool, + selected: bool, +) -> dict[str, object]: + frames = tuple(sorted(directory.glob("frame-*.png"))) + if not frames or fallback not in frames: + raise NarrativeAssetError("renderer did not publish a selected fallback frame") + animation = directory / "trace.gif" + contact_sheet = directory / "contact-sheet.png" + return { + **metadata, + "scope": { + "complete": complete, + "selected": selected, + "truncated": False, + }, + "animation": _artifact(root, animation, "image/gif"), + "fallback": _artifact(root, fallback, "image/png"), + "contact_sheet": _artifact(root, contact_sheet, "image/png"), + "frames": [_artifact(root, frame, "image/png") for frame in frames], + } + + +def _static_record( + root: Path, + path: Path, + metadata: dict[str, str], +) -> dict[str, object]: + return {**metadata, "artifact": _artifact(root, path, "image/png")} + + +def _run_artifact( + run_root: Path, run: dict[str, object], name: str +) -> Path | None: + record = run["artifacts"][name] + if record is None: + return None + return run_root / record["path"] + + +def _identities(run: dict[str, object]) -> dict[str, str | None]: + identities: dict[str, str | None] = { + "source_formula": run["source"]["sha256"], + "formula_snapshot": run["reduction"]["formula_sha256"], + "tileset": run["reduction"]["tileset_sha256"], + "region": run["reduction"]["region_sha256"], + "provenance": run["reduction"]["provenance_sha256"], + "boolean_z3_summary": run["boolean_z3"]["encoding_summary_sha256"], + "reference_trace_manifest": run["reference"]["trace"]["manifest_sha256"], + "reference_trace": run["reference"]["trace"]["trace_sha256"], + "reference_solution": run["reference"]["solution_sha256"], + "optimized_trace_manifest": run["optimized"]["trace"]["manifest_sha256"], + "optimized_trace": run["optimized"]["trace"]["trace_sha256"], + "optimized_solution": run["optimized"]["solution_sha256"], + "wang_z3_summary": run["wang_z3"]["encoding_summary_sha256"], + } + if tuple(identities) != IDENTITY_NAMES: + raise NarrativeAssetError("narrative identity order diverged from contract") + return identities + + +def _animation_metadata( + identities: dict[str, str | None], + run: dict[str, object], +) -> dict[str, dict[str, str]]: + pipeline_digest = pipeline_source_sha256(identities, run) + mechanism_digest = hashlib.sha256(MECHANISM_SOURCE.read_bytes()).hexdigest() + if mechanism_digest != OPTIMIZED_MECHANISMS_SHA256: + raise NarrativeAssetError( + "optimized mechanism source changed without a narrative contract version" + ) + return { + "pipeline_overview": _metadata( + owner="/pipeline/", + semantic_label="observed", + caption="One validated v2 capture in fixed component order.", + alt_text="The captured formula moves through Boolean Z3, Yang-Zhang reduction, both native solvers, Wang Z3, verification, and presentation.", + source_contract="wang-run-dossier-v2#named-components", + source_sha256=pipeline_digest, + producer="dossier.narrative_assets", + validator="formats.run_dossier_v2_bundle.load_run_dossier_v2", + compositor="renderer.wang_narrative.render_overview_assets", + ), + "boolean_z3": _metadata( + owner="/components/boolean-z3/", + semantic_label="encoding-order", + caption="Project-owned Boolean constraint construction and returned assignment.", + alt_text="Four frames add Boolean variables and source-order exactly-one clauses before showing the copied result.", + source_contract="z3-encoding-summary-v1", + source_sha256=str(identities["boolean_z3_summary"]), + producer="formats.z3_encoding_summary.build_boolean_z3_summary", + validator="renderer.wang_z3_summary.load_z3_encoding_summary", + compositor="renderer.wang_z3_summary.render_boolean_z3_assets", + ), + "region_construction": _metadata( + owner="/components/yang-zhang/", + semantic_label="canonical-construction", + caption="Native Yang-Zhang gadget spans accumulated over the observed region.", + alt_text="Six frames reveal variable, forwarding, crossover, and clause gadget spans on the same region.", + source_contract="wang-reduction-explanation-v1", + source_sha256=str(identities["provenance"]), + producer="formats.solver_trace_snapshot.dump_solver_trace_bundle", + validator="renderer.wang_snapshot.load_explainability_bundle", + compositor="renderer.wang_algorithm_animation.render_builder_assets", + ), + "reference_trace": _metadata( + owner="/components/reference-solver/", + semantic_label="observed", + caption="Selected semantic milestones from the complete reference trace.", + alt_text="Observed reference domain states at root, propagation, decision, search, and result milestones.", + source_contract="wang-explain-manifest-v3", + source_sha256=str(identities["reference_trace_manifest"]), + producer="formats.solver_trace_snapshot.dump_solver_trace_bundle", + validator="renderer.wang_trace.load_trace_bundle", + compositor="renderer.wang_trace_render.render_trace_assets", + ), + "optimized_trace": _metadata( + owner="/components/optimized-solver/", + semantic_label="observed", + caption="Selected semantic milestones from the complete optimized trace.", + alt_text="Observed optimized domain states at root, propagation, decision, search, and result milestones.", + source_contract="wang-explain-manifest-v3", + source_sha256=str(identities["optimized_trace_manifest"]), + producer="formats.solver_trace_snapshot.dump_solver_trace_bundle", + validator="renderer.wang_trace.load_trace_bundle", + compositor="renderer.wang_trace_render.render_trace_assets", + ), + "wang_z3": _metadata( + owner="/components/wang-z3/", + semantic_label="encoding-order", + caption="Project-owned Wang edge-term construction and returned model.", + alt_text="Five frames add edge terms, shared internal edges, tile relations, boundaries, and the copied result.", + source_contract="z3-encoding-summary-v1", + source_sha256=str(identities["wang_z3_summary"]), + producer="formats.z3_encoding_summary.build_wang_z3_summary", + validator="renderer.wang_z3_summary.load_z3_encoding_summary", + compositor="renderer.wang_z3_summary.render_wang_z3_assets", + ), + "verification": _metadata( + owner="/components/verification/", + semantic_label="observed", + caption="The six named independent checker records from the captured run.", + alt_text="Six frames report Boolean, native, and Wang Z3 witness checks without rerunning a verifier.", + source_contract="wang-run-dossier-v2#verification", + source_sha256=verification_source_sha256(run), + producer="formats.run_dossier_v2_builder.build_run_dossier_v2", + validator="formats.run_dossier_v2.validate_run_dossier_v2+renderer.wang_narrative._load_verification", + compositor="renderer.wang_narrative.render_verification_assets", + ), + "witness_presentation": _metadata( + owner="/components/visualization/", + semantic_label="verified-transformation", + caption="Verified square witness, exact generalized overlay, and checked hex port.", + alt_text="Four frames move from the verified square witness through generalized recognition to the checked hex presentation.", + source_contract="wang-solution-v1+wang-generalized-tiles-v1+checked-square-to-hex", + source_sha256=witness_presentation_source_sha256( + str(identities["reference_solution"]) + ), + producer="formats.solver_trace_snapshot.dump_solver_trace_bundle", + validator="renderer.wang_square.load_wang_presentation+renderer.wang_generalized.recognize_generalized_tiles+renderer.wang_hex_port.check_square_to_hex", + compositor="renderer.wang_narrative.render_witness_assets", + ), + "optimized_mechanisms": _metadata( + owner="/components/optimized-solver/", + semantic_label="didactic", + caption="The six retained serial mechanisms, including the lazy MRV index.", + alt_text="Seven didactic frames contrast the reference baseline with six measured optimized mechanisms.", + source_contract="wang-optimized-mechanisms-v1", + source_sha256=mechanism_digest, + producer="renderer/data/optimized-mechanisms-v1.json", + validator="renderer.wang_algorithm_animation._load_optimizations", + compositor="renderer.wang_algorithm_animation.render_optimized_assets", + ), + } + + +def _static_metadata( + identities: dict[str, str | None], + run: dict[str, object], +) -> dict[str, dict[str, str]]: + generalized_digest = generalized_source_sha256(str(identities["tileset"])) + solution_digest = str(identities["reference_solution"]) + common = {"producer": "dossier.narrative_assets"} + return { + "home_preview": _metadata( + owner="/", + semantic_label="observed", + caption="Selected verified SAT square output for the captured instance.", + alt_text="A compact square Wang witness preview for the captured SAT source.", + source_contract="wang-solution-v1", + source_sha256=solution_digest, + validator="formats.run_dossier_v2_bundle.load_run_dossier_v2", + compositor="renderer.wang_narrative.render_overview_assets", + **common, + ), + "worked_example": _metadata( + owner="/worked-example/", + semantic_label="observed", + caption="Static t0 through tn component milestones for one SAT run.", + alt_text="Eight static panels follow the captured source from formula to checked presentation.", + source_contract="wang-run-dossier-v2#named-components", + source_sha256=pipeline_source_sha256(identities, run), + validator="formats.run_dossier_v2_bundle.load_run_dossier_v2", + compositor="renderer.wang_narrative.render_overview_assets", + **common, + ), + "formula": _metadata( + owner="/worked-example/", + semantic_label="observed", + caption="Parsed formula snapshot for the named canonical source.", + alt_text="The parsed CM1-in-3 formula and its source-order clauses.", + source_contract="cm13-formula-snapshot-v1", + source_sha256=str(identities["formula_snapshot"]), + validator="renderer.wang_snapshot.load_explainability_bundle", + compositor="renderer.wang_snapshot.render_pipeline_snapshot", + **common, + ), + "generalized_sheet": _metadata( + owner="/components/tileset/", + semantic_label="canonical-construction", + caption="The exact 14 generalized tiles decomposed into 23 positional atomic IDs.", + alt_text="A sheet of fourteen Yang-Zhang generalized tiles with internal seams and atomic identifiers.", + source_contract="wang-generalized-tiles-v1+wang-tileset-snapshot-v1", + source_sha256=generalized_digest, + validator="renderer.wang_snapshot.load_explainability_bundle+renderer.wang_generalized.check_canonical_atomic_tileset", + compositor="renderer.wang_narrative.render_generalized_assets", + **common, + ), + "atomic_legend": _metadata( + owner="/components/tileset/", + semantic_label="canonical-construction", + caption="All 23 atomic IDs with symbolic paper colors and generalized roles.", + alt_text="A semantic legend for twenty-three positional Wang tiles and their edge colors.", + source_contract="wang-generalized-tiles-v1+wang-tileset-snapshot-v1", + source_sha256=generalized_digest, + validator="renderer.wang_snapshot.load_explainability_bundle+renderer.wang_generalized.check_canonical_atomic_tileset", + compositor="renderer.wang_narrative.render_generalized_assets", + **common, + ), + "square_presentation": _metadata( + owner="/components/visualization/", + semantic_label="observed", + caption="The independently verified square witness with atomic IDs and boundaries.", + alt_text="The complete square Wang witness for the captured SAT source.", + source_contract="wang-solution-v1", + source_sha256=solution_digest, + validator="renderer.wang_square.load_wang_presentation", + compositor="renderer.wang_narrative.render_witness_assets", + **common, + ), + "generalized_presentation": _metadata( + owner="/components/visualization/", + semantic_label="canonical-construction", + caption="Exact generalized contours over the same verified square witness.", + alt_text="The square witness grouped into exact Yang-Zhang generalized tile occurrences.", + source_contract="wang-solution-v1+wang-generalized-tiles-v1", + source_sha256=generalized_presentation_source_sha256(solution_digest), + validator="renderer.wang_square.load_wang_presentation+renderer.wang_generalized.recognize_generalized_tiles", + compositor="renderer.wang_narrative.render_witness_assets", + **common, + ), + "hex_presentation": _metadata( + owner="/components/visualization/", + semantic_label="verified-transformation", + caption="The checked Basire/Culik square-to-hex port of the same witness.", + alt_text="A pointy-top hex presentation preserving the square witness cells, edges, and boundary.", + source_contract="wang-solution-v1+checked-square-to-hex", + source_sha256=hex_presentation_source_sha256(solution_digest), + validator="renderer.wang_square.load_wang_presentation+renderer.wang_hex_port.check_square_to_hex", + compositor="renderer.wang_narrative.render_witness_assets", + **common, + ), + "presentation_status": _metadata( + owner="/run-dossiers/", + semantic_label="observed", + caption="UNSAT has no witness-only presentation artifacts.", + alt_text="A notice that square, generalized, and hex witness views are not applicable to the UNSAT run.", + source_contract="wang-run-dossier-v2#agreement", + source_sha256=verification_source_sha256(run), + validator="formats.run_dossier_v2_bundle.load_run_dossier_v2", + compositor="renderer.wang_narrative.render_presentation_status", + **common, + ), + } + + +def _install_directory(staging: Path, destination: Path) -> None: + os.replace(staging, destination) + + +def attach_narrative_assets( + run: dict[str, object], + dossier_root: str | Path, + manifest_path: str | Path, +) -> dict[str, object]: + """Bind selected milestones and the three reserved static presentations.""" + updated = copy.deepcopy(run) + root = Path(dossier_root).resolve() + manifest_file = Path(manifest_path).resolve() + manifest = load_narrative_assets(manifest_file, updated) + if manifest["product"] != "run-specific": + raise NarrativeAssetError("only run-specific assets can be attached to run.json") + for solver, animation_name in ( + ("reference", "reference_trace"), + ("optimized", "optimized_trace"), + ): + selected = len(manifest["animations"][animation_name]["frames"]) + updated[solver]["trace"]["selection"] = { + "performed": True, + "selected_event_count": selected, + } + + if updated["case"]["expected_status"] == "sat": + specifications = { + "square": ( + "observed verified square witness presentation", + "observed", + ), + "generalized": ( + "exact generalized overlay on the verified square witness", + "canonical-construction", + ), + "hex": ( + "checked square-to-hex witness presentation", + "verified-transformation", + ), + } + for name, (role, semantics) in specifications.items(): + static = manifest["statics"][f"{name}_presentation"]["artifact"] + absolute = manifest_file.parent / static["path"] + try: + relative = absolute.relative_to(root).as_posix() + except ValueError as error: + raise NarrativeAssetError( + f"{name} presentation lies outside dossier" + ) from error + artifact_name = f"{name}_presentation" + updated["artifacts"][artifact_name] = { + "path": relative, + "sha256": static["sha256"], + "media_type": "image/png", + "schema": None, + "role": role, + "semantics": semantics, + "form": "static", + "source_sha256": updated["source"]["sha256"], + } + updated["presentation"][name]["artifact"] = artifact_name + validate_run_dossier_v2(updated) + return updated + + +def generate_narrative_assets( + run_path: str | Path, + output_directory: str | Path, + *, + product: str = "run-specific", +) -> Path: + """Render one fixed asset pass and atomically install its closed manifest.""" + run_file = Path(run_path).resolve() + run = load_run_dossier_v2(run_file) + if product not in {"run-specific", "canonical-pages"}: + raise NarrativeAssetError(f"unsupported asset product: {product}") + if product == "canonical-pages" and { + "id": run["case"]["id"], + "expected_status": run["case"]["expected_status"], + "source_sha256": run["source"]["sha256"], + } != CANONICAL_PAGES_CASE: + raise NarrativeAssetError( + "canonical-pages is reserved for the canonical pipeline SAT case" + ) + destination = Path(output_directory).resolve() + if destination.exists(): + raise NarrativeAssetError(f"output directory already exists: {destination!s}") + destination.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=f".{destination.name}.", dir=destination.parent)) + try: + run_root = run_file.parent + reference_manifest = _run_artifact(run_root, run, "reference_trace_manifest") + optimized_manifest = _run_artifact(run_root, run, "optimized_trace_manifest") + boolean_summary = _run_artifact(run_root, run, "boolean_z3_summary") + wang_summary = _run_artifact(run_root, run, "wang_z3_summary") + if None in (reference_manifest, optimized_manifest, boolean_summary, wang_summary): + raise NarrativeAssetError("v2 run is missing a required narrative source") + + formula_path = staging / "formula.png" + _run_renderer( + ["wang_square.py", str(reference_manifest), str(formula_path), "--view", "formula"] + ) + boolean_outputs = _run_renderer( + ["wang_z3_summary.py", str(boolean_summary), str(staging / "boolean-z3")], + required_outputs=("fallback",), + ) + region_outputs = _run_renderer( + [ + "wang_algorithm_animation.py", + "builder", + str(reference_manifest), + str(staging / "region-construction"), + ], + required_outputs=("fallback",), + ) + reference_outputs = _run_renderer( + [ + "wang_trace_render.py", + str(reference_manifest), + str(staging / "reference-trace"), + "--max-frames", + "10", + ], + required_outputs=("fallback",), + ) + optimized_outputs = _run_renderer( + [ + "wang_trace_render.py", + str(optimized_manifest), + str(staging / "optimized-trace"), + "--max-frames", + "10", + ], + required_outputs=("fallback",), + ) + wang_outputs = _run_renderer( + ["wang_z3_summary.py", str(wang_summary), str(staging / "wang-z3")], + required_outputs=("fallback",), + ) + verification_receipts = staging / ".verification-receipts.json" + _write_atomic( + verification_receipts, + _encode_document(_verification_receipts(run)), + ) + verification_outputs = _run_renderer( + [ + "wang_narrative.py", + "verification", + str(verification_receipts), + str(staging / "verification"), + ], + required_outputs=("fallback",), + ) + verification_receipts.unlink() + mechanism_outputs = _run_renderer( + ["wang_algorithm_animation.py", "optimized", str(staging / "optimized-mechanisms")], + required_outputs=("fallback",), + ) + generalized_outputs = _run_renderer( + [ + "wang_narrative.py", + "generalized", + str(reference_manifest), + str(staging / "generalized-tiles"), + ], + required_outputs=("spec_sha256",), + ) + if str(generalized_outputs.get("spec_sha256", "")) != GENERALIZED_SPECIFICATION_SHA256: + raise NarrativeAssetError( + "renderer generalized specification disagrees with the narrative contract" + ) + + status = run["case"]["expected_status"] + witness_outputs: dict[str, Path] | None = None + if status == "sat": + solution = _run_artifact(run_root, run, "reference_solution") + if solution is None: + raise NarrativeAssetError("SAT narrative assets require a reference solution") + witness_outputs = _run_renderer( + ["wang_narrative.py", "witness", str(solution), str(staging / "presentation")], + required_outputs=("square", "generalized", "hex", "fallback"), + ) + presentation_source = witness_outputs["hex"] + home_source = witness_outputs["square"] + else: + presentation_source = staging / "presentation-status.png" + _run_renderer( + ["wang_narrative.py", "status", "unsat", str(presentation_source)] + ) + home_source = presentation_source + + overview_arguments = [ + "wang_narrative.py", + "overview", + str(staging / "pipeline-overview"), + str(formula_path), + str(boolean_outputs["fallback"]), + str(region_outputs["fallback"]), + str(reference_outputs["fallback"]), + str(optimized_outputs["fallback"]), + str(wang_outputs["fallback"]), + str(verification_outputs["fallback"]), + str(presentation_source), + "--home-source", + str(home_source), + ] + if status == "unsat": + overview_arguments.append("--omit-sat-story") + overview_required = ("fallback",) + if status == "sat": + overview_required += ("home_preview", "worked_example") + overview_outputs = _run_renderer( + overview_arguments, required_outputs=overview_required + ) + + identities = _identities(run) + animation_metadata = _animation_metadata(identities, run) + animation_directories = { + "pipeline_overview": (staging / "pipeline-overview", overview_outputs), + "boolean_z3": (staging / "boolean-z3", boolean_outputs), + "region_construction": (staging / "region-construction", region_outputs), + "reference_trace": (staging / "reference-trace", reference_outputs), + "optimized_trace": (staging / "optimized-trace", optimized_outputs), + "wang_z3": (staging / "wang-z3", wang_outputs), + "verification": (staging / "verification", verification_outputs), + "witness_presentation": ( + None if witness_outputs is None else staging / "presentation", + witness_outputs, + ), + "optimized_mechanisms": (staging / "optimized-mechanisms", mechanism_outputs), + } + animations: dict[str, dict[str, object] | None] = {} + for name in ANIMATION_NAMES: + directory, outputs = animation_directories[name] + if directory is None or outputs is None: + animations[name] = None + continue + animations[name] = _animation_record( + staging, + directory, + outputs["fallback"], + animation_metadata[name], + complete=True, + selected=name + in { + "pipeline_overview", + "region_construction", + "reference_trace", + "optimized_trace", + }, + ) + + static_metadata = _static_metadata(identities, run) + static_paths: dict[str, Path | None] = { + "home_preview": overview_outputs.get("home_preview"), + "worked_example": overview_outputs.get("worked_example"), + "formula": formula_path, + "generalized_sheet": staging / "generalized-tiles/sheet.png", + "atomic_legend": staging / "generalized-tiles/atomic-legend.png", + "square_presentation": None if witness_outputs is None else witness_outputs["square"], + "generalized_presentation": None + if witness_outputs is None + else witness_outputs["generalized"], + "hex_presentation": None if witness_outputs is None else witness_outputs["hex"], + "presentation_status": presentation_source if status == "unsat" else None, + } + statics: dict[str, dict[str, object] | None] = { + name: None + if static_paths[name] is None + else _static_record(staging, static_paths[name], static_metadata[name]) + for name in STATIC_NAMES + } + manifest = { + "schema": SCHEMA_NAME, + "product": product, + "case": { + "id": run["case"]["id"], + "expected_status": status, + "source_sha256": run["source"]["sha256"], + }, + "identities": identities, + "animations": animations, + "statics": statics, + "pdf_milestones": { + "selector": "semantic-milestones-v1", + "region_construction": [ + item["path"] for item in animations["region_construction"]["frames"] + ], + "reference_trace": [ + item["path"] for item in animations["reference_trace"]["frames"] + ], + "optimized_trace": [ + item["path"] for item in animations["optimized_trace"]["frames"] + ], + "end_to_end": [ + item["path"] for item in animations["pipeline_overview"]["frames"] + ], + }, + } + manifest_path = staging / "manifest.json" + _write_atomic(manifest_path, _encode_document(manifest)) + load_narrative_assets(manifest_path, run) + _install_directory(staging, destination) + return destination / "manifest.json" + except Exception: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="generate one closed shared narrative asset bundle from a v2 run" + ) + parser.add_argument("run", type=Path) + parser.add_argument("output_directory", type=Path) + parser.add_argument( + "--product", + choices=("run-specific", "canonical-pages"), + default="run-specific", + ) + return parser + + +def main(arguments: list[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(arguments) + try: + manifest = generate_narrative_assets( + args.run, args.output_directory, product=args.product + ) + except (NarrativeAssetError, OSError, ValueError) as error: + parser.error(str(error)) + print(f"manifest={manifest}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/formats/narrative_assets.py b/python/formats/narrative_assets.py new file mode 100644 index 0000000..27e69b0 --- /dev/null +++ b/python/formats/narrative_assets.py @@ -0,0 +1,692 @@ +"""Closed manifest for shared narrative assets derived from one v2 run.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Final + +from formats.pipeline_snapshot import ( + PipelineSnapshotError, + _encode_document, + _load_json_bytes, + _require_exact_fields, + _require_literal, + _require_object, + _require_sha256, +) +from formats.run_contract import _boolean, _nonempty_string, _relative_path +from formats.run_dossier_v2 import validate_run_dossier_v2 + + +SCHEMA_NAME: Final = "wang-narrative-assets-v1" +OPTIMIZED_MECHANISMS_SHA256: Final = ( + "5f0c6e1f80601f6112ae8e358fe8029fa3d94fd260693edf0b37ea474419f212" +) +GENERALIZED_SPECIFICATION_SHA256: Final = ( + "5e8e6589271f9059b5ed81df00db4e303b338d5243caa475ed09135b129e3cf2" +) +HEX_TRANSFORMATION_ID: Final = "basire-culik-square-to-hex-v1" +CANONICAL_PAGES_CASE: Final = { + "id": "pipeline-sat-v2", + "expected_status": "sat", + "source_sha256": "3caaa6b29ac988fb4f51cc7071202d83ea1591ba6170e683b6da449cb3641542", +} +PRODUCTS: Final = frozenset({"run-specific", "canonical-pages"}) +SEMANTIC_LABELS: Final = frozenset( + { + "observed", + "canonical-construction", + "encoding-order", + "verified-transformation", + "didactic", + } +) +ANIMATION_NAMES: Final = ( + "pipeline_overview", + "boolean_z3", + "region_construction", + "reference_trace", + "optimized_trace", + "wang_z3", + "verification", + "witness_presentation", + "optimized_mechanisms", +) +STATIC_NAMES: Final = ( + "home_preview", + "worked_example", + "formula", + "generalized_sheet", + "atomic_legend", + "square_presentation", + "generalized_presentation", + "hex_presentation", + "presentation_status", +) +IDENTITY_NAMES: Final = ( + "source_formula", + "formula_snapshot", + "tileset", + "region", + "provenance", + "boolean_z3_summary", + "reference_trace_manifest", + "reference_trace", + "reference_solution", + "optimized_trace_manifest", + "optimized_trace", + "optimized_solution", + "wang_z3_summary", +) +_ARTIFACT_FIELDS = frozenset({"path", "sha256", "media_type"}) +_ANIMATION_FIELDS = frozenset( + { + "owner", + "semantic_label", + "caption", + "alt_text", + "source_contract", + "source_sha256", + "producer", + "validator", + "compositor", + "scope", + "animation", + "fallback", + "contact_sheet", + "frames", + } +) +_STATIC_FIELDS = frozenset( + { + "owner", + "semantic_label", + "caption", + "alt_text", + "source_contract", + "source_sha256", + "producer", + "validator", + "compositor", + "artifact", + } +) +_ANIMATION_POLICY: Final = { + "pipeline_overview": ("/pipeline/", "observed"), + "boolean_z3": ("/components/boolean-z3/", "encoding-order"), + "region_construction": ("/components/yang-zhang/", "canonical-construction"), + "reference_trace": ("/components/reference-solver/", "observed"), + "optimized_trace": ("/components/optimized-solver/", "observed"), + "wang_z3": ("/components/wang-z3/", "encoding-order"), + "verification": ("/components/verification/", "observed"), + "witness_presentation": ( + "/components/visualization/", + "verified-transformation", + ), + "optimized_mechanisms": ("/components/optimized-solver/", "didactic"), +} +_STATIC_POLICY: Final = { + "home_preview": ("/", "observed"), + "worked_example": ("/worked-example/", "observed"), + "formula": ("/worked-example/", "observed"), + "generalized_sheet": ("/components/tileset/", "canonical-construction"), + "atomic_legend": ("/components/tileset/", "canonical-construction"), + "square_presentation": ("/components/visualization/", "observed"), + "generalized_presentation": ( + "/components/visualization/", + "canonical-construction", + ), + "hex_presentation": ( + "/components/visualization/", + "verified-transformation", + ), + "presentation_status": ("/run-dossiers/", "observed"), +} +_SELECTED_ANIMATIONS: Final = frozenset( + { + "pipeline_overview", + "region_construction", + "reference_trace", + "optimized_trace", + } +) +_ANIMATION_TOOLCHAIN: Final = { + "pipeline_overview": ( + "dossier.narrative_assets", + "formats.run_dossier_v2_bundle.load_run_dossier_v2", + "renderer.wang_narrative.render_overview_assets", + ), + "boolean_z3": ( + "formats.z3_encoding_summary.build_boolean_z3_summary", + "renderer.wang_z3_summary.load_z3_encoding_summary", + "renderer.wang_z3_summary.render_boolean_z3_assets", + ), + "region_construction": ( + "formats.solver_trace_snapshot.dump_solver_trace_bundle", + "renderer.wang_snapshot.load_explainability_bundle", + "renderer.wang_algorithm_animation.render_builder_assets", + ), + "reference_trace": ( + "formats.solver_trace_snapshot.dump_solver_trace_bundle", + "renderer.wang_trace.load_trace_bundle", + "renderer.wang_trace_render.render_trace_assets", + ), + "optimized_trace": ( + "formats.solver_trace_snapshot.dump_solver_trace_bundle", + "renderer.wang_trace.load_trace_bundle", + "renderer.wang_trace_render.render_trace_assets", + ), + "wang_z3": ( + "formats.z3_encoding_summary.build_wang_z3_summary", + "renderer.wang_z3_summary.load_z3_encoding_summary", + "renderer.wang_z3_summary.render_wang_z3_assets", + ), + "verification": ( + "formats.run_dossier_v2_builder.build_run_dossier_v2", + "formats.run_dossier_v2.validate_run_dossier_v2+renderer.wang_narrative._load_verification", + "renderer.wang_narrative.render_verification_assets", + ), + "witness_presentation": ( + "formats.solver_trace_snapshot.dump_solver_trace_bundle", + "renderer.wang_square.load_wang_presentation+renderer.wang_generalized.recognize_generalized_tiles+renderer.wang_hex_port.check_square_to_hex", + "renderer.wang_narrative.render_witness_assets", + ), + "optimized_mechanisms": ( + "renderer/data/optimized-mechanisms-v1.json", + "renderer.wang_algorithm_animation._load_optimizations", + "renderer.wang_algorithm_animation.render_optimized_assets", + ), +} +_STATIC_TOOLCHAIN: Final = { + "home_preview": ( + "dossier.narrative_assets", + "formats.run_dossier_v2_bundle.load_run_dossier_v2", + "renderer.wang_narrative.render_overview_assets", + ), + "worked_example": ( + "dossier.narrative_assets", + "formats.run_dossier_v2_bundle.load_run_dossier_v2", + "renderer.wang_narrative.render_overview_assets", + ), + "formula": ( + "dossier.narrative_assets", + "renderer.wang_snapshot.load_explainability_bundle", + "renderer.wang_snapshot.render_pipeline_snapshot", + ), + "generalized_sheet": ( + "dossier.narrative_assets", + "renderer.wang_snapshot.load_explainability_bundle+renderer.wang_generalized.check_canonical_atomic_tileset", + "renderer.wang_narrative.render_generalized_assets", + ), + "atomic_legend": ( + "dossier.narrative_assets", + "renderer.wang_snapshot.load_explainability_bundle+renderer.wang_generalized.check_canonical_atomic_tileset", + "renderer.wang_narrative.render_generalized_assets", + ), + "square_presentation": ( + "dossier.narrative_assets", + "renderer.wang_square.load_wang_presentation", + "renderer.wang_narrative.render_witness_assets", + ), + "generalized_presentation": ( + "dossier.narrative_assets", + "renderer.wang_square.load_wang_presentation+renderer.wang_generalized.recognize_generalized_tiles", + "renderer.wang_narrative.render_witness_assets", + ), + "hex_presentation": ( + "dossier.narrative_assets", + "renderer.wang_square.load_wang_presentation+renderer.wang_hex_port.check_square_to_hex", + "renderer.wang_narrative.render_witness_assets", + ), + "presentation_status": ( + "dossier.narrative_assets", + "formats.run_dossier_v2_bundle.load_run_dossier_v2", + "renderer.wang_narrative.render_presentation_status", + ), +} + + +def verification_source_sha256(run: dict[str, object]) -> str: + return hashlib.sha256( + _encode_document( + { + "verification": run["verification"], + "agreement": run["agreement"], + } + ) + ).hexdigest() + + +def composite_source_sha256(values: dict[str, str]) -> str: + return hashlib.sha256(_encode_document(values)).hexdigest() + + +def generalized_source_sha256(tileset_sha256: str) -> str: + return composite_source_sha256( + { + "tileset": tileset_sha256, + "generalized_specification": GENERALIZED_SPECIFICATION_SHA256, + } + ) + + +def generalized_presentation_source_sha256(solution_sha256: str) -> str: + return composite_source_sha256( + { + "solution": solution_sha256, + "generalized_specification": GENERALIZED_SPECIFICATION_SHA256, + } + ) + + +def hex_presentation_source_sha256(solution_sha256: str) -> str: + return composite_source_sha256( + { + "solution": solution_sha256, + "transformation": HEX_TRANSFORMATION_ID, + } + ) + + +def witness_presentation_source_sha256(solution_sha256: str) -> str: + return composite_source_sha256( + { + "solution": solution_sha256, + "generalized_specification": GENERALIZED_SPECIFICATION_SHA256, + "transformation": HEX_TRANSFORMATION_ID, + } + ) + + +def pipeline_source_sha256( + identities: dict[str, str | None], run: dict[str, object] +) -> str: + sources = { + name: digest for name, digest in identities.items() if digest is not None + } + sources["verification"] = verification_source_sha256(run) + sources["generalized_specification"] = GENERALIZED_SPECIFICATION_SHA256 + sources["square_to_hex_transformation"] = HEX_TRANSFORMATION_ID + return composite_source_sha256(sources) + + +def _artifact( + value: object, + path: str, + root: Path, + *, + media_type: str, +) -> str: + artifact = _require_object(value, path) + _require_exact_fields(artifact, _ARTIFACT_FIELDS, path) + relative = _relative_path(artifact["path"], f"{path}.path") + _require_sha256(artifact["sha256"], f"{path}.sha256") + _require_literal(artifact["media_type"], media_type, f"{path}.media_type") + try: + unresolved = root / relative + cursor = root + for component in Path(relative).parts: + cursor /= component + if cursor.is_symlink(): + raise PipelineSnapshotError(f"{path}.path: symlinks are forbidden") + candidate = unresolved.resolve(strict=True) + if not candidate.is_relative_to(root) or not candidate.is_file(): + raise PipelineSnapshotError(f"{path}.path: escapes asset bundle") + encoded = candidate.read_bytes() + except PipelineSnapshotError: + raise + except OSError as error: + raise PipelineSnapshotError(f"{path}.path: cannot read artifact: {error}") from error + if hashlib.sha256(encoded).hexdigest() != artifact["sha256"]: + raise PipelineSnapshotError(f"{path}.sha256: does not match file") + return relative + + +def _metadata( + value: dict[str, object], + path: str, + policy: tuple[str, str], + toolchain: tuple[str, str, str], +) -> None: + owner, semantic_label = policy + _require_literal(value["owner"], owner, f"{path}.owner") + label = _nonempty_string(value["semantic_label"], f"{path}.semantic_label") + if label not in SEMANTIC_LABELS or label != semantic_label: + raise PipelineSnapshotError(f"{path}.semantic_label: disagrees with policy") + for name in ("caption", "alt_text", "source_contract"): + _nonempty_string(value[name], f"{path}.{name}") + for name, expected in zip( + ("producer", "validator", "compositor"), toolchain, strict=True + ): + _require_literal(value[name], expected, f"{path}.{name}") + _require_sha256(value["source_sha256"], f"{path}.source_sha256") + + +def validate_narrative_assets( + document: object, + manifest_path: str | Path, +) -> dict[str, set[str]]: + manifest = _require_object(document, "$") + _require_exact_fields( + manifest, + frozenset( + { + "schema", + "product", + "case", + "identities", + "animations", + "statics", + "pdf_milestones", + } + ), + "$", + ) + _require_literal(manifest["schema"], SCHEMA_NAME, "$.schema") + product = _nonempty_string(manifest["product"], "$.product") + if product not in PRODUCTS: + raise PipelineSnapshotError("$.product: is unsupported") + case = _require_object(manifest["case"], "$.case") + _require_exact_fields( + case, frozenset({"id", "expected_status", "source_sha256"}), "$.case" + ) + _nonempty_string(case["id"], "$.case.id") + status = _nonempty_string(case["expected_status"], "$.case.expected_status") + if status not in {"sat", "unsat"}: + raise PipelineSnapshotError("$.case.expected_status: is unsupported") + _require_sha256(case["source_sha256"], "$.case.source_sha256") + if product == "canonical-pages" and case != CANONICAL_PAGES_CASE: + raise PipelineSnapshotError( + "$.case: canonical-pages is reserved for the canonical SAT run" + ) + + identities = _require_object(manifest["identities"], "$.identities") + _require_exact_fields(identities, frozenset(IDENTITY_NAMES), "$.identities") + for name in IDENTITY_NAMES: + value = identities[name] + if value is None: + if status == "sat" or not name.endswith("_solution"): + raise PipelineSnapshotError(f"$.identities.{name}: may not be null") + else: + _require_sha256(value, f"$.identities.{name}") + + root = Path(manifest_path).parent.resolve(strict=True) + animations = _require_object(manifest["animations"], "$.animations") + _require_exact_fields(animations, frozenset(ANIMATION_NAMES), "$.animations") + owned_paths: set[str] = set() + frame_paths: dict[str, tuple[str, ...]] = {} + for name in ANIMATION_NAMES: + path = f"$.animations.{name}" + raw = animations[name] + if raw is None: + if name != "witness_presentation" or status != "unsat": + raise PipelineSnapshotError(f"{path}: is required") + frame_paths[name] = () + continue + record = _require_object(raw, path) + _require_exact_fields(record, _ANIMATION_FIELDS, path) + _metadata(record, path, _ANIMATION_POLICY[name], _ANIMATION_TOOLCHAIN[name]) + scope = _require_object(record["scope"], f"{path}.scope") + _require_exact_fields( + scope, frozenset({"complete", "selected", "truncated"}), f"{path}.scope" + ) + if not _boolean(scope["complete"], f"{path}.scope.complete"): + raise PipelineSnapshotError(f"{path}.scope.complete: must remain true") + selected = _boolean(scope["selected"], f"{path}.scope.selected") + if selected != (name in _SELECTED_ANIMATIONS): + raise PipelineSnapshotError(f"{path}.scope.selected: disagrees with policy") + if _boolean(scope["truncated"], f"{path}.scope.truncated"): + raise PipelineSnapshotError(f"{path}.scope.truncated: must remain false") + animation_path = _artifact( + record["animation"], f"{path}.animation", root, media_type="image/gif" + ) + contact_path = _artifact( + record["contact_sheet"], + f"{path}.contact_sheet", + root, + media_type="image/png", + ) + raw_frames = record["frames"] + if type(raw_frames) is not list or not raw_frames: + raise PipelineSnapshotError(f"{path}.frames: must be a nonempty array") + ordered_paths = tuple( + _artifact(item, f"{path}.frames[{index}]", root, media_type="image/png") + for index, item in enumerate(raw_frames) + ) + paths = set(ordered_paths) + if len(paths) != len(ordered_paths): + raise PipelineSnapshotError(f"{path}.frames: contains duplicate paths") + fallback_path = _artifact( + record["fallback"], f"{path}.fallback", root, media_type="image/png" + ) + if fallback_path not in paths: + raise PipelineSnapshotError(f"{path}.fallback: must select one frame") + newly_owned = paths | {animation_path, contact_path} + overlap = owned_paths & newly_owned + if overlap: + raise PipelineSnapshotError(f"{path}: reuses files owned by another asset") + owned_paths.update(newly_owned) + frame_paths[name] = ordered_paths + + statics = _require_object(manifest["statics"], "$.statics") + _require_exact_fields(statics, frozenset(STATIC_NAMES), "$.statics") + sat_only = { + "home_preview", + "worked_example", + "square_presentation", + "generalized_presentation", + "hex_presentation", + } + for name in STATIC_NAMES: + path = f"$.statics.{name}" + raw = statics[name] + if raw is None: + allowed = (status == "unsat" and name in sat_only) or ( + status == "sat" and name == "presentation_status" + ) + if not allowed: + raise PipelineSnapshotError(f"{path}: is required") + continue + if (status == "sat" and name == "presentation_status") or ( + status == "unsat" and name in sat_only + ): + raise PipelineSnapshotError(f"{path}: disagrees with SAT applicability") + record = _require_object(raw, path) + _require_exact_fields(record, _STATIC_FIELDS, path) + _metadata(record, path, _STATIC_POLICY[name], _STATIC_TOOLCHAIN[name]) + artifact_path = _artifact( + record["artifact"], f"{path}.artifact", root, media_type="image/png" + ) + if artifact_path in owned_paths: + raise PipelineSnapshotError(f"{path}: reuses a file owned by another asset") + owned_paths.add(artifact_path) + + milestones = _require_object(manifest["pdf_milestones"], "$.pdf_milestones") + _require_exact_fields( + milestones, + frozenset( + { + "selector", + "region_construction", + "reference_trace", + "optimized_trace", + "end_to_end", + } + ), + "$.pdf_milestones", + ) + _require_literal( + milestones["selector"], "semantic-milestones-v1", "$.pdf_milestones.selector" + ) + milestone_sources = { + "region_construction": "region_construction", + "reference_trace": "reference_trace", + "optimized_trace": "optimized_trace", + "end_to_end": "pipeline_overview", + } + for name, animation_name in milestone_sources.items(): + values = milestones[name] + if type(values) is not list or not values: + raise PipelineSnapshotError(f"$.pdf_milestones.{name}: must be nonempty") + if any(type(value) is not str for value in values): + raise PipelineSnapshotError(f"$.pdf_milestones.{name}: paths must be strings") + if tuple(values) != frame_paths[animation_name]: + raise PipelineSnapshotError( + f"$.pdf_milestones.{name}: must exactly preserve the shared frame order" + ) + if any(not value.endswith(".png") for value in values): + raise PipelineSnapshotError(f"$.pdf_milestones.{name}: GIF is forbidden") + return frame_paths + + +def load_narrative_assets( + manifest_path: str | Path, + run_document: dict[str, object], +) -> dict[str, object]: + validate_run_dossier_v2(run_document) + path = Path(manifest_path) + if path.is_symlink(): + raise PipelineSnapshotError("narrative manifest may not be a symlink") + try: + document = _load_json_bytes(path.read_bytes(), str(path)) + except OSError as error: + raise PipelineSnapshotError(f"cannot read narrative manifest: {error}") from error + validate_narrative_assets(document, path) + if document["case"] != { + "id": run_document["case"]["id"], + "expected_status": run_document["case"]["expected_status"], + "source_sha256": run_document["source"]["sha256"], + }: + raise PipelineSnapshotError("narrative case identity disagrees with run") + expected_identities = { + "source_formula": run_document["source"]["sha256"], + "formula_snapshot": run_document["reduction"]["formula_sha256"], + "tileset": run_document["reduction"]["tileset_sha256"], + "region": run_document["reduction"]["region_sha256"], + "provenance": run_document["reduction"]["provenance_sha256"], + "boolean_z3_summary": run_document["boolean_z3"]["encoding_summary_sha256"], + "reference_trace_manifest": run_document["reference"]["trace"]["manifest_sha256"], + "reference_trace": run_document["reference"]["trace"]["trace_sha256"], + "reference_solution": run_document["reference"]["solution_sha256"], + "optimized_trace_manifest": run_document["optimized"]["trace"]["manifest_sha256"], + "optimized_trace": run_document["optimized"]["trace"]["trace_sha256"], + "optimized_solution": run_document["optimized"]["solution_sha256"], + "wang_z3_summary": run_document["wang_z3"]["encoding_summary_sha256"], + } + if document["identities"] != expected_identities: + raise PipelineSnapshotError("narrative component identities disagree with run") + animations = document["animations"] + pipeline_digest = pipeline_source_sha256(expected_identities, run_document) + generalized_digest = generalized_source_sha256( + expected_identities["tileset"] + ) + expected_sources = { + "pipeline_overview": ( + "wang-run-dossier-v2#named-components", + pipeline_digest, + ), + "boolean_z3": ("z3-encoding-summary-v1", expected_identities["boolean_z3_summary"]), + "region_construction": ( + "wang-reduction-explanation-v1", + expected_identities["provenance"], + ), + "reference_trace": ( + "wang-explain-manifest-v3", + expected_identities["reference_trace_manifest"], + ), + "optimized_trace": ( + "wang-explain-manifest-v3", + expected_identities["optimized_trace_manifest"], + ), + "wang_z3": ("z3-encoding-summary-v1", expected_identities["wang_z3_summary"]), + "verification": ( + "wang-run-dossier-v2#verification", + verification_source_sha256(run_document), + ), + "optimized_mechanisms": ( + "wang-optimized-mechanisms-v1", + OPTIMIZED_MECHANISMS_SHA256, + ), + } + if run_document["case"]["expected_status"] == "sat": + expected_sources["witness_presentation"] = ( + "wang-solution-v1+wang-generalized-tiles-v1+checked-square-to-hex", + witness_presentation_source_sha256( + expected_identities["reference_solution"] + ), + ) + for name, (contract, digest) in expected_sources.items(): + record = animations[name] + if record["source_contract"] != contract or record["source_sha256"] != digest: + raise PipelineSnapshotError(f"narrative source {name} disagrees with run") + + solution_digest = expected_identities["reference_solution"] + expected_static_sources = { + "formula": ( + "cm13-formula-snapshot-v1", + expected_identities["formula_snapshot"], + ), + "generalized_sheet": ( + "wang-generalized-tiles-v1+wang-tileset-snapshot-v1", + generalized_digest, + ), + "atomic_legend": ( + "wang-generalized-tiles-v1+wang-tileset-snapshot-v1", + generalized_digest, + ), + } + if run_document["case"]["expected_status"] == "sat": + assert solution_digest is not None + expected_static_sources.update( + { + "home_preview": ("wang-solution-v1", solution_digest), + "worked_example": ( + "wang-run-dossier-v2#named-components", + pipeline_digest, + ), + "square_presentation": ("wang-solution-v1", solution_digest), + "generalized_presentation": ( + "wang-solution-v1+wang-generalized-tiles-v1", + generalized_presentation_source_sha256(solution_digest), + ), + "hex_presentation": ( + "wang-solution-v1+checked-square-to-hex", + hex_presentation_source_sha256(solution_digest), + ), + } + ) + else: + expected_static_sources["presentation_status"] = ( + "wang-run-dossier-v2#agreement", + verification_source_sha256(run_document), + ) + for name, (contract, digest) in expected_static_sources.items(): + record = document["statics"][name] + if record["source_contract"] != contract or record["source_sha256"] != digest: + raise PipelineSnapshotError( + f"narrative static source {name} disagrees with run" + ) + if ( + document["product"] == "run-specific" + and run_document["case"]["expected_status"] == "sat" + ): + for name in ("square", "generalized", "hex"): + narrative_artifact = document["statics"][f"{name}_presentation"][ + "artifact" + ] + run_artifact = run_document["artifacts"][f"{name}_presentation"] + expected_run_path = ( + Path("assets/narrative") / narrative_artifact["path"] + ).as_posix() + if ( + run_artifact is not None + and ( + run_artifact["path"] != expected_run_path + or run_artifact["sha256"] != narrative_artifact["sha256"] + ) + ): + raise PipelineSnapshotError( + f"narrative {name} presentation disagrees with run artifact" + ) + return document diff --git a/python/formats/run_dossier_v2_bundle.py b/python/formats/run_dossier_v2_bundle.py index 40b090e..0639683 100644 --- a/python/formats/run_dossier_v2_bundle.py +++ b/python/formats/run_dossier_v2_bundle.py @@ -201,4 +201,60 @@ def load_run_dossier_v2(path: str | Path) -> dict[str, object]: raise PipelineSnapshotError("Boolean Z3 witness digest mismatch") if document["wang_z3"]["witness_sha256"] != witness_sha256(wang_cells): raise PipelineSnapshotError("Wang Z3 witness digest mismatch") + narrative_path = run_path.parent / "assets/narrative/manifest.json" + narrative_expected = any( + document[solver]["trace"]["selection"]["performed"] + for solver in ("reference", "optimized") + ) or any( + document["artifacts"][f"{name}_presentation"] is not None + for name in ("square", "generalized", "hex") + ) + if narrative_expected and not narrative_path.is_file(): + raise PipelineSnapshotError( + "selected narrative assets require assets/narrative/manifest.json" + ) + if narrative_path.is_file(): + cursor = run_path.parent + for component in Path("assets/narrative/manifest.json").parts: + cursor /= component + if cursor.is_symlink(): + raise PipelineSnapshotError( + "assets/narrative and its manifest may not contain symlinks" + ) + try: + resolved_narrative = narrative_path.resolve(strict=True) + except OSError as error: + raise PipelineSnapshotError( + f"cannot resolve narrative manifest: {error}" + ) from error + if not resolved_narrative.is_relative_to(root): + raise PipelineSnapshotError("narrative manifest escapes dossier") + from formats.narrative_assets import load_narrative_assets + + narrative = load_narrative_assets(resolved_narrative, document) + if narrative["product"] != "run-specific": + raise PipelineSnapshotError( + "a dossier may contain only a run-specific narrative manifest" + ) + for solver, animation_name in ( + ("reference", "reference_trace"), + ("optimized", "optimized_trace"), + ): + selection = document[solver]["trace"]["selection"] + selected = len(narrative["animations"][animation_name]["frames"]) + if not selection["performed"]: + raise PipelineSnapshotError( + f"{solver} narrative manifest requires a performed selection" + ) + if selection["selected_event_count"] != selected: + raise PipelineSnapshotError( + f"{solver} selected event count disagrees with narrative manifest" + ) + if document["case"]["expected_status"] == "sat" and any( + document["artifacts"][f"{name}_presentation"] is None + for name in ("square", "generalized", "hex") + ): + raise PipelineSnapshotError( + "SAT narrative manifest requires all presentation artifacts" + ) return document diff --git a/renderer/README.md b/renderer/README.md index 10a237e..20f8aa8 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -220,20 +220,24 @@ dossier generator reuse one trace bundle without synthesizing a second v1/v2 manifest. The renderer still imports neither native code nor Z3, and it does not own LaTeX or report metadata. -Z3 uses a separate `encoding-order` summary. The renderer shows the explicit -project-owned row-major constraint construction and returned model, never an -internal Z3 search or debug trace: +Z3 uses separate `encoding-order` summaries. The same offline consumer renders +the explicit project-owned Boolean source-order construction or Wang row-major +construction and returned model, never an internal Z3 search or debug trace: ```bash +uv run --locked python wang_z3_summary.py \ + ../tests/fixtures/pipeline_sat_z3/boolean-z3.json \ + output/boolean-z3-encoding uv run --locked python wang_z3_summary.py \ ../tests/fixtures/pipeline_sat_z3/wang-z3.json \ output/z3-encoding ``` -Three additional commands make their source semantics explicit: builder and -hex are `canonical-example` views over versioned data and pure checked -transformation respectively; optimized is a `didactic` overview whose dated -reports, not the animation, establish performance. +Three additional commands make their source semantics explicit: builder is a +`canonical-construction`, hex is a `verified-transformation`, and optimized is +a `didactic` overview whose dated reports, not the animation, establish +performance. The optimized source is the closed six-entry +`data/optimized-mechanisms-v1.json`, including the lazy MRV index. ```bash uv run --locked python wang_algorithm_animation.py builder \ @@ -246,6 +250,20 @@ uv run --locked python wang_algorithm_animation.py hex \ output/square-to-hex ``` +`wang_narrative.py` adds only fixed downstream compositions for verification, +the square/generalized/hex witness sequence, the generalized sheet and legend, +and the eight-component overview. The root shared-asset generator invokes +these commands after validating `run.json`; no renderer imports native code, +Z3, Pages, or LaTeX. SAT and UNSAT records use the same pipeline order, while +UNSAT emits an explicit not-applicable presentation instead of a witness. + +The trace frame selector is `semantic-milestones-v1`: it retains phase +transitions, propagation, decisions, conflicts, backtracks, terminal state, +and decision/backtrack reductions before filling the widest remaining replay +gaps. It is deterministic and deliberately not uniform time sampling. The +selected states are still composed from the same single validated replay used +for PNG frames, the reduced-motion fallback, contact sheet, and GIF. + All loaders reject unknown fields and identity drift before rasterization. No animation module imports native code; trace rendering and algorithm rendering do not import Z3. PNG sources are the deterministic test oracles. GIFs exist @@ -406,6 +424,8 @@ Raises `RenderingException` on pipeline errors. ├── wang_generalized_render.py # Sheet, atomic legend, and square overlay ├── wang_animation.py # Deterministic PNG/contact-sheet/GIF encoder ├── wang_algorithm_animation.py # Canonical and didactic algorithm views +├── wang_narrative.py # Verification, witness, overview, and PDF-milestone compositions +├── data/optimized-mechanisms-v1.json # Closed six-mechanism didactic source ├── test_wang_square.py # Square loader, raster, isolation, CLI, and failures ├── test_wang_hex.py # Port proof obligations, hex raster, golden, and CLI ├── test_wang_snapshot.py # Snapshot contracts, views, isolation, and goldens @@ -413,6 +433,7 @@ Raises `RenderingException` on pipeline errors. ├── test_wang_z3_summary.py # Fixed encoding summaries and byte stability ├── test_wang_generalized.py # Exact mapping, rejection, isolation, and goldens ├── test_wang_algorithm_animation.py # Source checks and animation goldens +├── test_wang_narrative.py # Shared narrative compositions and accessibility fallbacks ├── input/ # Example input files (palette, scene, tiles, sprites) ├── output/ # Rendered PNG output goes here ├── test_data/ @@ -437,10 +458,10 @@ Raises `RenderingException` on pipeline errors. uv run --locked pytest -q ``` -The complete isolated suite has 283 tests: the 144 preserved legacy tests +The complete isolated suite has 290 tests: the 144 preserved legacy tests below, 45 Wang square tests, 24 square-to-hex/hex-raster tests, 29 static snapshot/explainability tests, 21 generalized-presentation tests, and 20 -trace/Z3/algorithm-animation tests. +trace/Z3/algorithm-animation tests plus 7 narrative/semantic-selection tests. To run only the original upstream suite: ```bash diff --git a/renderer/data/optimized-mechanisms-v1.json b/renderer/data/optimized-mechanisms-v1.json new file mode 100644 index 0000000..21de611 --- /dev/null +++ b/renderer/data/optimized-mechanisms-v1.json @@ -0,0 +1,41 @@ +{ + "schema": "wang-optimized-mechanisms-v1", + "mechanisms": [ + { + "id": "dynamic-dfs-stack", + "title": "Dynamic DFS stack", + "description": "grow from 16 frames instead of reserving all active cells", + "evidence_route": "/solver_dynamic_stack_2026-08-17/" + }, + { + "id": "initial-trail-omission", + "title": "Initial trail omission", + "description": "do not store undo entries that search cannot consume", + "evidence_route": "/solver_initial_trail_2026-08-17/" + }, + { + "id": "sat-ownership-transfer", + "title": "SAT ownership transfer", + "description": "publish the verified domain buffer without a result copy", + "evidence_route": "/solver_sat_ownership_2026-08-20/" + }, + { + "id": "byte-support-table", + "title": "Byte support table", + "description": "aggregate compatible tiles through three byte lookups", + "evidence_route": "/solver_byte_support_2026-08-20/" + }, + { + "id": "queue-deduplication", + "title": "Queue deduplication", + "description": "suppress an enqueue while the cell is already pending", + "evidence_route": "/solver_queue_dedup_2026-08-20/" + }, + { + "id": "lazy-mrv-index", + "title": "Lazy MRV index", + "description": "select after the root from private packed domain-size buckets", + "evidence_route": "/solver_mrv_index_2026-08-28/" + } + ] +} diff --git a/renderer/test_wang_algorithm_animation.py b/renderer/test_wang_algorithm_animation.py index ddcea16..3c9331f 100644 --- a/renderer/test_wang_algorithm_animation.py +++ b/renderer/test_wang_algorithm_animation.py @@ -63,13 +63,13 @@ def test_optimized_didactic_animation_is_byte_stable(tmp_path): first = render_optimized_assets(tmp_path / "first") render_optimized_assets(tmp_path / "second") - assert first.fallback.name == "frame-05.png" + assert first.fallback.name == "frame-06.png" _assert_stable_assets( tmp_path / "first", tmp_path / "second", GOLDENS / "optimized-mechanisms", - frame_count=6, - fallback_name="frame-05.png", + frame_count=7, + fallback_name="frame-06.png", ) @@ -91,5 +91,5 @@ def test_cli_reports_animation_and_fallback(tmp_path, capsys): assert main(["optimized", str(tmp_path)]) == 0 assert capsys.readouterr().out.splitlines() == [ f"animation={tmp_path / 'trace.gif'}", - f"fallback={tmp_path / 'frame-05.png'}", + f"fallback={tmp_path / 'frame-06.png'}", ] diff --git a/renderer/test_wang_narrative.py b/renderer/test_wang_narrative.py new file mode 100644 index 0000000..88d60f9 --- /dev/null +++ b/renderer/test_wang_narrative.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import sys + +from PIL import Image +import pytest + +from wang_hex_port import WangSquareRenderError +from wang_generalized import generalized_specification_sha256 +from wang_narrative import ( + render_generalized_assets, + render_overview_assets, + render_presentation_status, + render_verification_assets, + render_witness_assets, +) + + +RENDERER = Path(__file__).resolve().parent +ROOT = RENDERER.parent +MANIFEST = ROOT / "tests/fixtures/pipeline_sat_reduction_explain/manifest.json" +SOLUTION = ROOT / "tests/fixtures/wang_solution_v1_square_sat.json" + + +def _tree_bytes(directory: Path) -> dict[str, bytes]: + return { + path.relative_to(directory).as_posix(): path.read_bytes() + for path in directory.rglob("*") + if path.is_file() + } + + +def _run_record(status: str) -> dict[str, object]: + performed = status == "sat" + checks = {} + specifications = ( + ("boolean_z3_assignment", "oracles.witness_check.is_valid_assignment"), + ("reference_tiling", "oracles.tiling_check.is_valid_tiling"), + ("reference_assignment", "oracles.witness_check.is_valid_assignment"), + ("optimized_tiling", "oracles.tiling_check.is_valid_tiling"), + ("optimized_assignment", "oracles.witness_check.is_valid_assignment"), + ("wang_z3_tiling", "oracles.tiling_check.is_valid_tiling"), + ) + for name, checker in specifications: + checks[name] = { + "checker": checker, + "performed": performed, + "passed": True if performed else None, + "witness_sha256": "1" * 64 if performed else None, + } + agreement = { + "expected_status": status, + "boolean_z3_status": status, + "reference_status": status, + "optimized_status": status, + "wang_z3_status": status, + "all_status_equal": True, + "sat_witnesses_valid": True if status == "sat" else None, + "passed": True, + } + payload = {"verification": checks, "agreement": agreement} + source_sha256 = hashlib.sha256( + (json.dumps(payload, ensure_ascii=False, allow_nan=False, indent=2) + "\n").encode( + "utf-8" + ) + ).hexdigest() + return { + "schema": "wang-verification-receipts-v1", + "expected_status": status, + **payload, + "source_sha256": source_sha256, + } + + +def test_verification_composition_is_deterministic_for_sat_and_unsat(tmp_path): + for status in ("sat", "unsat"): + run = tmp_path / f"{status}.json" + run.write_text(json.dumps(_run_record(status)) + "\n", encoding="utf-8") + first = render_verification_assets(run, tmp_path / f"{status}-first") + render_verification_assets(run, tmp_path / f"{status}-second") + assert _tree_bytes(tmp_path / f"{status}-first") == _tree_bytes( + tmp_path / f"{status}-second" + ) + assert first.fallback.name == "frame-05.png" + with Image.open(first.animation) as animation: + assert animation.n_frames == 6 + + +def test_verification_composition_rejects_partial_or_forged_receipts(tmp_path): + source = tmp_path / "forged.json" + document = _run_record("sat") + del document["agreement"]["reference_status"] + source.write_text(json.dumps(document) + "\n", encoding="utf-8") + with pytest.raises(WangSquareRenderError, match="agreement must be closed"): + render_verification_assets(source, tmp_path / "output") + + +def test_witness_and_generalized_assets_reuse_checked_presentations(tmp_path): + first = render_witness_assets(SOLUTION, tmp_path / "witness-first") + render_witness_assets(SOLUTION, tmp_path / "witness-second") + assert _tree_bytes(tmp_path / "witness-first") == _tree_bytes( + tmp_path / "witness-second" + ) + assert first.animation.fallback.name == "frame-03.png" + assert first.square.is_file() + assert first.generalized.is_file() + assert first.hex.is_file() + + generalized = render_generalized_assets(MANIFEST, tmp_path / "generalized") + assert generalized.sheet.is_file() + assert generalized.legend.is_file() + assert generalized_specification_sha256() == ( + "5e8e6589271f9059b5ed81df00db4e303b338d5243caa475ed09135b129e3cf2" + ) + + +def test_overview_and_unsat_status_are_static_fallback_safe(tmp_path): + status = render_presentation_status("unsat", tmp_path / "status.png") + sources = (status,) * 8 + first = render_overview_assets( + sources, + status, + tmp_path / "overview-first", + include_sat_story=False, + ) + render_overview_assets( + sources, + status, + tmp_path / "overview-second", + include_sat_story=False, + ) + assert _tree_bytes(tmp_path / "overview-first") == _tree_bytes( + tmp_path / "overview-second" + ) + assert first.home_preview is None + assert first.worked_example is None + assert first.animation.contact_sheet.is_file() + + +def test_narrative_module_imports_no_native_or_z3_producer(): + assert "z3" not in sys.modules + assert not any(name.startswith("native") for name in sys.modules) diff --git a/renderer/test_wang_trace.py b/renderer/test_wang_trace.py index 180f5b2..c63c94c 100644 --- a/renderer/test_wang_trace.py +++ b/renderer/test_wang_trace.py @@ -13,7 +13,7 @@ from wang_hex_port import WangSquareRenderError from wang_trace import TraceEvent, TraceSnapshot, load_trace_bundle, replay_trace -from wang_trace_render import render_trace_assets +from wang_trace_render import render_trace_assets, select_semantic_milestones RENDERER = Path(__file__).resolve().parent @@ -116,6 +116,16 @@ def test_one_composition_chain_is_byte_stable_for_png_sheet_and_gif(tmp_path): assert animation.n_frames == 8 +def test_semantic_milestones_precede_gap_filling(): + bundle = load_trace_bundle(MANIFEST) + selected = select_semantic_milestones(bundle.trace.events, 8) + + assert selected == (0, 1, 2516, 2517, 2518, 2893, 2894, 2895) + assert selected != tuple( + slot * (len(bundle.trace.events) - 1) // 7 for slot in range(8) + ) + + def test_rejects_hash_tampering_before_parsing_trace(tmp_path): copied = tmp_path / "bundle" shutil.copytree(FIXTURE_DIRECTORY, copied) diff --git a/renderer/test_wang_z3_summary.py b/renderer/test_wang_z3_summary.py index 9319699..1f9b17d 100644 --- a/renderer/test_wang_z3_summary.py +++ b/renderer/test_wang_z3_summary.py @@ -8,7 +8,11 @@ import pytest from wang_hex_port import WangSquareRenderError -from wang_z3_summary import load_z3_encoding_summary, render_wang_z3_assets +from wang_z3_summary import ( + load_z3_encoding_summary, + render_boolean_z3_assets, + render_wang_z3_assets, +) RENDERER = Path(__file__).resolve().parent @@ -53,6 +57,18 @@ def test_encoding_order_animation_is_byte_stable_and_matches_goldens(tmp_path): assert animation.n_frames == 5 +def test_boolean_encoding_order_animation_is_byte_stable(tmp_path): + source = FIXTURES / "boolean-z3.json" + first = render_boolean_z3_assets(source, tmp_path / "first") + render_boolean_z3_assets(source, tmp_path / "second") + + assert _tree_bytes(tmp_path / "first") == _tree_bytes(tmp_path / "second") + assert first.fallback.name == "frame-02.png" + with Image.open(first.animation) as animation: + assert animation.format == "GIF" + assert animation.n_frames == 4 + + def test_rejects_unknown_fields_and_cross_engine_region_identity(tmp_path): source = json.loads( (FIXTURES / "boolean-z3.json").read_text(encoding="utf-8") diff --git a/renderer/wang_algorithm_animation.py b/renderer/wang_algorithm_animation.py index 53687ec..828ed3a 100644 --- a/renderer/wang_algorithm_animation.py +++ b/renderer/wang_algorithm_animation.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import json from pathlib import Path from PIL import Image, ImageDraw @@ -28,13 +29,52 @@ "right_forward": (169, 112, 191), "clause": (214, 91, 91), } -_OPTIMIZATIONS = ( - ("Dynamic DFS stack", "grow from 16 frames instead of reserving all active cells"), - ("Initial trail omission", "do not store undo entries that search cannot consume"), - ("SAT ownership transfer", "publish the verified domain buffer without a result copy"), - ("Byte support table", "aggregate compatible tiles through three byte lookups"), - ("Queue deduplication", "suppress an enqueue while the cell is already pending"), -) +_OPTIMIZATION_SOURCE = Path(__file__).resolve().parent / "data/optimized-mechanisms-v1.json" + + +def _load_optimizations() -> tuple[tuple[str, str], ...]: + try: + document = json.loads(_OPTIMIZATION_SOURCE.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise WangSquareRenderError( + f"cannot load optimized mechanism source: {error}" + ) from error + if type(document) is not dict or set(document) != {"schema", "mechanisms"}: + raise WangSquareRenderError("optimized mechanism source must be closed") + if document["schema"] != "wang-optimized-mechanisms-v1": + raise WangSquareRenderError("optimized mechanism source schema is unsupported") + mechanisms = document["mechanisms"] + if type(mechanisms) is not list or len(mechanisms) != 6: + raise WangSquareRenderError("optimized mechanism source must contain six entries") + result: list[tuple[str, str]] = [] + identifiers: list[str] = [] + for index, item in enumerate(mechanisms): + if type(item) is not dict or set(item) != { + "id", + "title", + "description", + "evidence_route", + }: + raise WangSquareRenderError( + f"optimized mechanism entry {index} must be closed" + ) + values = tuple(item.values()) + if any(type(value) is not str or not value for value in values): + raise WangSquareRenderError( + f"optimized mechanism entry {index} requires nonempty strings" + ) + if not item["evidence_route"].startswith("/"): + raise WangSquareRenderError( + f"optimized mechanism entry {index} has an invalid evidence route" + ) + identifiers.append(item["id"]) + result.append((item["title"], item["description"])) + if len(set(identifiers)) != len(identifiers) or identifiers[-1] != "lazy-mrv-index": + raise WangSquareRenderError("optimized mechanism IDs are invalid or incomplete") + return tuple(result) + + +_OPTIMIZATIONS = _load_optimizations() def _base_frame(title: str, subtitle: str, size: tuple[int, int]) -> tuple[Image.Image, ImageDraw.ImageDraw]: @@ -49,7 +89,7 @@ def _builder_frame(bundle: object, stage: int) -> Image.Image: assert reduction is not None image, draw = _base_frame( "Yang-Zhang construction provenance", - f"canonical-example stage {stage + 1}/6 | actual gadget spans, not a timed builder trace", + f"canonical-construction stage {stage + 1}/6 | actual gadget spans, not a timed builder trace", (980, 390), ) origin_x, origin_y, cell = 18, 92, 15 @@ -130,8 +170,8 @@ def render_builder_assets( def _optimized_frame(stage: int) -> Image.Image: image, draw = _base_frame( "Optimized serial mechanisms", - f"didactic stage {stage + 1}/6 | storage/work changes only; search semantics stay shared", - (960, 430), + f"didactic stage {stage + 1}/7 | storage/work changes only; search semantics stay shared", + (960, 500), ) draw.text( (18, 82), @@ -140,7 +180,7 @@ def _optimized_frame(stage: int) -> Image.Image: fill=EXPLAIN_TEXT_RGB, ) draw.rounded_rectangle( - (18, 106, 245, 392), + (18, 106, 245, 462), radius=7, fill=(239, 241, 245), outline=(170, 177, 190), @@ -151,11 +191,12 @@ def _optimized_frame(stage: int) -> Image.Image: "initial trail entries", "full DFS frame reserve", "verified SAT result copy", + "linear MRV scan after the root", ) y = 130 for line in baseline: draw.text((34, y), line, font=explain_font(10), fill=EXPLAIN_MUTED_RGB) - y += 46 + y += 48 draw.text((276, 82), "Retained optimized path", font=explain_font(14), fill=EXPLAIN_TEXT_RGB) for index, (name, description) in enumerate(_OPTIMIZATIONS): @@ -182,7 +223,7 @@ def _optimized_frame(stage: int) -> Image.Image: fill=EXPLAIN_TEXT_RGB if active else EXPLAIN_MUTED_RGB, ) draw.text( - (276, 400), + (276, 468), "Measured reports establish benefit separately; this animation claims no speedup.", font=explain_font(9), fill=EXPLAIN_MUTED_RGB, @@ -195,12 +236,12 @@ def render_optimized_assets( *, duration_ms: int = 750, ) -> AnimationOutputs: - frames = tuple(_optimized_frame(stage) for stage in range(6)) + frames = tuple(_optimized_frame(stage) for stage in range(7)) return write_animation_assets( frames, - tuple(f"frame-{stage:02d}.png" for stage in range(6)), + tuple(f"frame-{stage:02d}.png" for stage in range(7)), output_directory, - fallback_index=5, + fallback_index=6, duration_ms=duration_ms, ) @@ -232,7 +273,7 @@ def _draw_hex_tile(draw: ImageDraw.ImageDraw, origin: tuple[int, int], edges: tu def _hex_frame(square: object, port: object, stage: int) -> Image.Image: image, draw = _base_frame( "Square-to-hex presentation port", - f"canonical-example stage {stage + 1}/4 | pure Basire/Culik witness mapping", + f"verified-transformation stage {stage + 1}/4 | pure Basire/Culik witness mapping", (920, 430), ) source_edges = square.tile_edges[0] diff --git a/renderer/wang_generalized.py b/renderer/wang_generalized.py index 95ecea0..c3b72a2 100644 --- a/renderer/wang_generalized.py +++ b/renderer/wang_generalized.py @@ -12,6 +12,8 @@ from collections.abc import Sequence from dataclasses import dataclass +import hashlib +import json from typing import Final @@ -175,6 +177,41 @@ def _validate_fixed_specification() -> None: _validate_fixed_specification() +def generalized_specification_sha256() -> str: + """Hash the fixed semantic mapping independently of its raster layout.""" + document = { + "schema": "wang-generalized-tiles-v1", + "color_names": COLOR_NAMES, + "atomic_tile_edges": CANONICAL_ATOMIC_TILE_EDGES, + "generalized_tiles": [ + { + "name": tile.name, + "parts": [ + { + "tile_id": part.tile_id, + "dx": part.dx, + "dy": part.dy, + "label": part.label, + } + for part in tile.parts + ], + } + for tile in GENERALIZED_TILES + ], + } + encoded = ( + json.dumps( + document, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + + "\n" + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def color_label(color_id: int) -> str: """Return the symbolic paper/glue name with the numeric ID secondary.""" if type(color_id) is not int or not 0 <= color_id < len(COLOR_NAMES): diff --git a/renderer/wang_narrative.py b/renderer/wang_narrative.py new file mode 100644 index 0000000..ae5d3a2 --- /dev/null +++ b/renderer/wang_narrative.py @@ -0,0 +1,645 @@ +"""Fixed narrative compositions built from already validated pipeline outputs.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +import json +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +from wang_animation import AnimationOutputs, write_animation_assets +from wang_explain import ( + EXPLAIN_ACTIVE_RGB, + EXPLAIN_MUTED_RGB, + EXPLAIN_PANEL_RGB, + EXPLAIN_TEXT_RGB, + draw_explain_heading, + explain_font, +) +from wang_generalized_render import ( + compose_atomic_semantic_legend, + compose_generalized_overlay, + compose_generalized_sheet, +) +from wang_generalized import generalized_specification_sha256 +from wang_hex_port import WangSquareRenderError, check_square_to_hex, reduce_square_to_hex +from wang_snapshot import load_explainability_bundle +from wang_square import ( + _compose_wang_hex_explain, + _compose_wang_square_explain, + _save_png_atomic, + load_wang_presentation, +) + + +_PIPELINE_LABELS = ( + "CM1-in-3 formula", + "Boolean Z3", + "Yang-Zhang reduction", + "Reference solver", + "Optimized solver", + "Wang Z3", + "Independent verification", + "Square / generalized / hex", +) +_CHECKS = ( + ( + "boolean_z3_assignment", + "Boolean assignment", + "oracles.witness_check.is_valid_assignment", + ), + ("reference_tiling", "Reference tiling", "oracles.tiling_check.is_valid_tiling"), + ( + "reference_assignment", + "Reference assignment", + "oracles.witness_check.is_valid_assignment", + ), + ("optimized_tiling", "Optimized tiling", "oracles.tiling_check.is_valid_tiling"), + ( + "optimized_assignment", + "Optimized assignment", + "oracles.witness_check.is_valid_assignment", + ), + ("wang_z3_tiling", "Wang Z3 tiling", "oracles.tiling_check.is_valid_tiling"), +) + + +@dataclass(frozen=True, slots=True) +class WitnessOutputs: + square: Path + generalized: Path + hex: Path + animation: AnimationOutputs + + +@dataclass(frozen=True, slots=True) +class GeneralizedOutputs: + sheet: Path + legend: Path + + +@dataclass(frozen=True, slots=True) +class OverviewOutputs: + animation: AnimationOutputs + home_preview: Path | None + worked_example: Path | None + + +def _save_image(image: Image.Image, path: Path) -> None: + if image.mode != "RGB": + raise WangSquareRenderError("narrative output must be RGB") + _save_png_atomic(np.asarray(image, dtype=np.uint8), path) + + +def _load_image(path: Path) -> Image.Image: + try: + with Image.open(path) as source: + source.load() + return source.convert("RGB") + except (OSError, ValueError) as error: + raise WangSquareRenderError( + f"cannot load narrative source image {path!s}: {error}" + ) from error + + +def _fit(image: Image.Image, size: tuple[int, int]) -> Image.Image: + fitted = image.copy() + fitted.thumbnail(size, resample=Image.Resampling.NEAREST) + return fitted + + +def _load_verification(path: str | Path) -> tuple[str, tuple[dict[str, object], ...]]: + source = Path(path) + try: + document = json.loads(source.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise WangSquareRenderError( + f"cannot load verification receipt snapshot {source!s}: {error}" + ) from error + if type(document) is not dict or set(document) != { + "schema", + "expected_status", + "verification", + "agreement", + "source_sha256", + }: + raise WangSquareRenderError("verification receipt snapshot must be closed") + if document["schema"] != "wang-verification-receipts-v1": + raise WangSquareRenderError("verification receipt schema is unsupported") + status = document["expected_status"] + if type(status) is not str or status not in {"sat", "unsat"}: + raise WangSquareRenderError("verification receipt status is unsupported") + verification = document["verification"] + agreement = document["agreement"] + if type(verification) is not dict or set(verification) != { + name for name, _, _ in _CHECKS + }: + raise WangSquareRenderError("verification receipt record must be closed") + agreement_fields = { + "expected_status", + "boolean_z3_status", + "reference_status", + "optimized_status", + "wang_z3_status", + "all_status_equal", + "sat_witnesses_valid", + "passed", + } + if type(agreement) is not dict or set(agreement) != agreement_fields: + raise WangSquareRenderError("verification agreement must be closed") + if ( + agreement["expected_status"] != status + or any( + agreement[name] != status + for name in ( + "boolean_z3_status", + "reference_status", + "optimized_status", + "wang_z3_status", + ) + ) + or agreement["all_status_equal"] is not True + or agreement["passed"] is not True + or agreement["sat_witnesses_valid"] is not (True if status == "sat" else None) + ): + raise WangSquareRenderError("verification agreement is inconsistent") + records: list[dict[str, object]] = [] + for name, _, checker in _CHECKS: + record = verification[name] + if type(record) is not dict or set(record) != { + "checker", + "performed", + "passed", + "witness_sha256", + }: + raise WangSquareRenderError(f"verification record {name} must be closed") + if record["checker"] != checker: + raise WangSquareRenderError(f"verification record {name} checker drifted") + if status == "sat": + digest = record["witness_sha256"] + valid = ( + record["performed"] is True + and record["passed"] is True + and type(digest) is str + and len(digest) == 64 + and all(character in "0123456789abcdef" for character in digest) + ) + else: + valid = ( + record["performed"] is False + and record["passed"] is None + and record["witness_sha256"] is None + ) + if not valid: + raise WangSquareRenderError(f"verification record {name} is inconsistent") + records.append(record) + encoded_source = ( + json.dumps( + {"verification": verification, "agreement": agreement}, + ensure_ascii=False, + allow_nan=False, + indent=2, + ) + + "\n" + ).encode("utf-8") + source_sha256 = document["source_sha256"] + if ( + type(source_sha256) is not str + or hashlib.sha256(encoded_source).hexdigest() != source_sha256 + ): + raise WangSquareRenderError("verification receipt source hash drifted") + return status, tuple(records) + + +def _verification_frame( + status: str, + records: tuple[dict[str, object], ...], + stage: int, +) -> Image.Image: + image = Image.new("RGB", (960, 500), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(image) + draw_explain_heading( + draw, + (18, 16), + title="Independent verification sequence", + subtitle=( + f"observed record {stage + 1}/6 | expected {status.upper()} | " + "renders checker receipts; does not rerun a verifier" + ), + ) + for index, ((_, label, expected_prefix), record) in enumerate( + zip(_CHECKS, records, strict=True) + ): + y = 92 + index * 60 + visible = index <= stage + performed = record.get("performed") is True + passed = record.get("passed") is True + checker = str(record.get("checker", "")) + if performed and passed and checker.startswith(expected_prefix): + state = "performed / passed" + fill = (213, 237, 224) + elif status == "unsat" and not performed and record.get("passed") is None: + state = "not applicable: no SAT witness" + fill = (235, 238, 243) + else: + raise WangSquareRenderError( + f"verification record {_CHECKS[index][0]} is inconsistent" + ) + draw.rounded_rectangle( + (18, y, 930, y + 46), + radius=6, + fill=fill if visible else (242, 244, 247), + outline=(53, 144, 93) if index == stage else (181, 188, 199), + width=2 if index == stage else 1, + ) + draw.text( + (32, y + 7), + label, + font=explain_font(12), + fill=EXPLAIN_TEXT_RGB if visible else EXPLAIN_MUTED_RGB, + ) + draw.text( + (278, y + 8), + state, + font=explain_font(11), + fill=EXPLAIN_TEXT_RGB if visible else EXPLAIN_MUTED_RGB, + ) + draw.text( + (548, y + 9), + checker, + font=explain_font(9), + fill=EXPLAIN_MUTED_RGB, + ) + draw.text( + (18, 472), + "SAT checks validate named witnesses independently; UNSAT has no fabricated certificate.", + font=explain_font(9), + fill=EXPLAIN_MUTED_RGB, + ) + return image + + +def render_verification_assets( + run_path: str | Path, + output_directory: str | Path, + *, + duration_ms: int = 750, +) -> AnimationOutputs: + status, records = _load_verification(run_path) + frames = tuple( + _verification_frame(status, records, stage) for stage in range(len(_CHECKS)) + ) + return write_animation_assets( + frames, + tuple(f"frame-{stage:02d}.png" for stage in range(len(frames))), + output_directory, + fallback_index=len(frames) - 1, + duration_ms=duration_ms, + ) + + +def _presentation_frame( + square: Image.Image, + generalized: Image.Image, + hex_image: Image.Image, + stage: int, +) -> Image.Image: + image = Image.new("RGB", (1080, 620), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(image) + labels = ( + "verified square witness", + "exact generalized recognition", + "pure Basire/Culik port", + "checked hex presentation", + ) + draw_explain_heading( + draw, + (18, 16), + title="Verified witness presentation", + subtitle=f"verified-transformation stage {stage + 1}/4 | {labels[stage]}", + ) + sources = (square, generalized, generalized, hex_image) + fitted = _fit(sources[stage], (1020, 475)) + image.paste( + fitted, + ((1080 - fitted.width) // 2, 88 + (475 - fitted.height) // 2), + ) + if stage == 2: + draw.rounded_rectangle( + (246, 508, 834, 565), + radius=7, + fill=(239, 242, 246), + outline=(55, 126, 168), + width=2, + ) + draw.text( + (278, 525), + "H(N,E,S,W) = (E,S,kappa,W,N,kappa); inverse and matching checked", + font=explain_font(11), + fill=EXPLAIN_TEXT_RGB, + ) + draw.text( + (18, 594), + "The independent square witness check precedes presentation; raster output proves nothing by itself.", + font=explain_font(9), + fill=EXPLAIN_MUTED_RGB, + ) + return image + + +def render_witness_assets( + solution_path: str | Path, + output_directory: str | Path, + *, + duration_ms: int = 850, +) -> WitnessOutputs: + destination = Path(output_directory) + destination.mkdir(parents=True, exist_ok=True) + presentation = load_wang_presentation(solution_path) + port = reduce_square_to_hex(presentation) + check_square_to_hex(presentation, port) + square = Image.fromarray(_compose_wang_square_explain(presentation), mode="RGB") + generalized = Image.fromarray(compose_generalized_overlay(presentation), mode="RGB") + hex_image = Image.fromarray(_compose_wang_hex_explain(presentation), mode="RGB") + square_path = destination / "square.png" + generalized_path = destination / "generalized.png" + hex_path = destination / "hex.png" + _save_image(square, square_path) + _save_image(generalized, generalized_path) + _save_image(hex_image, hex_path) + animation = write_animation_assets( + tuple( + _presentation_frame(square, generalized, hex_image, stage) + for stage in range(4) + ), + tuple(f"frame-{stage:02d}.png" for stage in range(4)), + destination, + fallback_index=3, + duration_ms=duration_ms, + ) + return WitnessOutputs(square_path, generalized_path, hex_path, animation) + + +def render_generalized_assets( + manifest_path: str | Path, + output_directory: str | Path, +) -> GeneralizedOutputs: + bundle = load_explainability_bundle(manifest_path) + destination = Path(output_directory) + destination.mkdir(parents=True, exist_ok=True) + sheet = destination / "sheet.png" + legend = destination / "atomic-legend.png" + _save_png_atomic(compose_generalized_sheet(bundle.tileset.tile_edges), sheet) + _save_png_atomic( + compose_atomic_semantic_legend(bundle.tileset.tile_edges), legend + ) + return GeneralizedOutputs(sheet, legend) + + +def render_presentation_status(status: str, output_path: str | Path) -> Path: + if status != "unsat": + raise WangSquareRenderError("presentation status is only valid for UNSAT") + image = Image.new("RGB", (960, 500), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(image) + draw_explain_heading( + draw, + (18, 16), + title="Witness presentation", + subtitle="observed | UNSAT | square, generalized overlay, and hex are not applicable", + ) + draw.rounded_rectangle( + (145, 142, 815, 350), + radius=10, + fill=(239, 242, 246), + outline=(181, 188, 199), + ) + draw.text( + (257, 188), + "No SAT witness was returned", + font=explain_font(20), + fill=EXPLAIN_TEXT_RGB, + ) + draw.text( + (213, 242), + "No square, generalized, or hex witness is fabricated.", + font=explain_font(13), + fill=EXPLAIN_MUTED_RGB, + ) + draw.text( + (226, 285), + "The trace records search; it is not an UNSAT certificate.", + font=explain_font(12), + fill=EXPLAIN_MUTED_RGB, + ) + destination = Path(output_path) + destination.parent.mkdir(parents=True, exist_ok=True) + _save_image(image, destination) + return destination + + +def _pipeline_frame(sources: tuple[Image.Image, ...], stage: int) -> Image.Image: + image = Image.new("RGB", (1080, 620), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(image) + draw_explain_heading( + draw, + (18, 16), + title="One captured v2 pipeline run", + subtitle=( + f"observed milestone {stage + 1}/8 | {_PIPELINE_LABELS[stage]} | " + "component order, not a timing scale" + ), + ) + node_width = 126 + for index, label in enumerate(_PIPELINE_LABELS): + x = 18 + index * 130 + active = index <= stage + draw.rounded_rectangle( + (x, 88, x + node_width - 8, 144), + radius=6, + fill=(213, 237, 224) if active else (240, 242, 246), + outline=(52, 145, 94) if index == stage else (181, 188, 199), + width=2 if index == stage else 1, + ) + words = label.replace(" / ", "/").split() + draw.text( + (x + 7, 96), + "\n".join(words[:3]), + font=explain_font(8), + fill=EXPLAIN_TEXT_RGB if active else EXPLAIN_MUTED_RGB, + spacing=2, + ) + if index < len(_PIPELINE_LABELS) - 1: + draw.line((x + 118, 116, x + 130, 116), fill=(129, 139, 153), width=2) + fitted = _fit(sources[stage], (1020, 400)) + image.paste( + fitted, + ((1080 - fitted.width) // 2, 166 + (400 - fitted.height) // 2), + ) + draw.text( + (18, 594), + "All facts come from one validated v2 run; independent checks remain separate from rendering.", + font=explain_font(9), + fill=EXPLAIN_MUTED_RGB, + ) + return image + + +def _home_preview(square: Image.Image) -> Image.Image: + image = Image.new("RGB", (760, 430), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(image) + draw_explain_heading( + draw, + (18, 16), + title="Verified SAT witness", + subtitle="observed | captured SAT source | square presentation", + ) + fitted = _fit(square, (720, 320)) + image.paste(fitted, ((760 - fitted.width) // 2, 82)) + draw.text( + (18, 407), + "Preview only; the worked example retains the full source and trust boundary.", + font=explain_font(9), + fill=EXPLAIN_MUTED_RGB, + ) + return image + + +def _worked_example(sources: tuple[Image.Image, ...]) -> Image.Image: + image = Image.new("RGB", (1080, 940), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(image) + draw_explain_heading( + draw, + (18, 16), + title="Captured SAT semantic milestones", + subtitle="observed | fixed component order | static reduced-motion sequence", + ) + card_width, card_height = 510, 195 + for index, (label, source) in enumerate(zip(_PIPELINE_LABELS, sources, strict=True)): + column = index % 2 + row = index // 2 + x = 18 + column * 528 + y = 82 + row * 210 + draw.rounded_rectangle( + (x, y, x + card_width, y + card_height), + radius=7, + fill=EXPLAIN_ACTIVE_RGB, + outline=(181, 188, 199), + ) + draw.text((x + 10, y + 8), f"t{index} {label}", font=explain_font(11), fill=EXPLAIN_TEXT_RGB) + fitted = _fit(source, (card_width - 20, card_height - 42)) + image.paste(fitted, (x + (card_width - fitted.width) // 2, y + 35)) + draw.text( + (18, 918), + "Milestones are semantic selections, not uniformly sampled time points.", + font=explain_font(9), + fill=EXPLAIN_MUTED_RGB, + ) + return image + + +def render_overview_assets( + source_paths: tuple[str | Path, ...], + home_source: str | Path, + output_directory: str | Path, + *, + duration_ms: int = 900, + include_sat_story: bool = True, +) -> OverviewOutputs: + if len(source_paths) != len(_PIPELINE_LABELS): + raise WangSquareRenderError("pipeline overview requires eight named sources") + sources = tuple(_load_image(Path(path)) for path in source_paths) + home_image = _load_image(Path(home_source)) if include_sat_story else None + destination = Path(output_directory) + destination.mkdir(parents=True, exist_ok=True) + frames = tuple(_pipeline_frame(sources, stage) for stage in range(len(sources))) + animation = write_animation_assets( + frames, + tuple(f"frame-{stage:02d}.png" for stage in range(len(frames))), + destination, + fallback_index=len(frames) - 1, + duration_ms=duration_ms, + ) + home_preview: Path | None = None + worked_example: Path | None = None + if home_image is not None: + home_preview = destination / "home-preview.png" + worked_example = destination / "worked-example.png" + _save_image(_home_preview(home_image), home_preview) + _save_image(_worked_example(sources), worked_example) + return OverviewOutputs(animation, home_preview, worked_example) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="compose fixed narrative assets from validated pipeline outputs" + ) + subparsers = parser.add_subparsers(dest="mode", required=True) + verification = subparsers.add_parser("verification") + verification.add_argument("run", type=Path) + verification.add_argument("output_directory", type=Path) + witness = subparsers.add_parser("witness") + witness.add_argument("solution", type=Path) + witness.add_argument("output_directory", type=Path) + generalized = subparsers.add_parser("generalized") + generalized.add_argument("manifest", type=Path) + generalized.add_argument("output_directory", type=Path) + status = subparsers.add_parser("status") + status.add_argument("status", choices=("unsat",)) + status.add_argument("output", type=Path) + overview = subparsers.add_parser("overview") + overview.add_argument("output_directory", type=Path) + overview.add_argument("sources", nargs=8, type=Path) + overview.add_argument("--home-source", required=True, type=Path) + overview.add_argument("--omit-sat-story", action="store_true") + return parser + + +def main(arguments: list[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(arguments) + try: + if args.mode == "verification": + outputs = render_verification_assets(args.run, args.output_directory) + print(f"animation={outputs.animation}") + print(f"contact_sheet={outputs.contact_sheet}") + print(f"fallback={outputs.fallback}") + elif args.mode == "witness": + outputs = render_witness_assets(args.solution, args.output_directory) + print(f"square={outputs.square}") + print(f"generalized={outputs.generalized}") + print(f"hex={outputs.hex}") + print(f"animation={outputs.animation.animation}") + print(f"contact_sheet={outputs.animation.contact_sheet}") + print(f"fallback={outputs.animation.fallback}") + elif args.mode == "generalized": + outputs = render_generalized_assets(args.manifest, args.output_directory) + print(f"sheet={outputs.sheet}") + print(f"legend={outputs.legend}") + print(f"spec_sha256={generalized_specification_sha256()}") + elif args.mode == "status": + print(f"status={render_presentation_status(args.status, args.output)}") + else: + outputs = render_overview_assets( + tuple(args.sources), + args.home_source, + args.output_directory, + include_sat_story=not args.omit_sat_story, + ) + print(f"animation={outputs.animation.animation}") + print(f"contact_sheet={outputs.animation.contact_sheet}") + print(f"fallback={outputs.animation.fallback}") + if outputs.home_preview is not None: + print(f"home_preview={outputs.home_preview}") + if outputs.worked_example is not None: + print(f"worked_example={outputs.worked_example}") + except (FileNotFoundError, WangSquareRenderError) as error: + parser.error(str(error)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/renderer/wang_trace_render.py b/renderer/wang_trace_render.py index 76d455d..b161cd5 100644 --- a/renderer/wang_trace_render.py +++ b/renderer/wang_trace_render.py @@ -33,27 +33,57 @@ _CHANGED_RGB: Final = (245, 178, 60) -def _selected_indices(events: tuple[TraceEvent, ...], maximum: int) -> tuple[int, ...]: +def select_semantic_milestones( + events: tuple[TraceEvent, ...], maximum: int +) -> tuple[int, ...]: + """Select semantic transitions first, then fill the widest replay gaps. + + This is intentionally different from uniform frame sampling. The selected + states still come from the single validated replay performed by the caller. + """ if type(maximum) is not int or not 2 <= maximum <= 32: raise WangSquareRenderError("max_frames must be in [2, 32]") count = len(events) if count <= maximum: return tuple(range(count)) + priority = [0, count - 1] - for kind in ("propagation", "decision", "conflict", "backtrack"): - match = next( - (index for index, event in enumerate(events) if event.kind == kind), - None, - ) - if match is not None: - priority.append(match) + for index in range(1, count): + if events[index].phase != events[index - 1].phase: + priority.extend((index - 1, index)) + for kind in ( + "propagation", + "decision", + "conflict", + "backtrack", + "domain_reduction", + ): + matches = [ + index for index, event in enumerate(events) if event.kind == kind + ] + if matches: + priority.extend((matches[0], matches[-1])) + priority.extend( + index + for index, event in enumerate(events) + if event.kind == "domain_reduction" + and event.reason in {"decision", "backtrack"} + ) + deepest_decisions = [ + (event.depth, index) + for index, event in enumerate(events) + if event.kind == "decision" + ] + if deepest_decisions: + priority.append(max(deepest_decisions)[1]) + selected = list(dict.fromkeys(priority))[:maximum] - for slot in range(maximum): - index = slot * (count - 1) // (maximum - 1) - if index not in selected: - selected.append(index) - if len(selected) == maximum: - break + while len(selected) < maximum: + candidate = max( + (index for index in range(count) if index not in selected), + key=lambda index: (min(abs(index - item) for item in selected), -index), + ) + selected.append(candidate) return tuple(sorted(selected)) @@ -187,7 +217,7 @@ def render_trace_assets( """Replay once, compose each selected frame once, then encode assets.""" bundle = load_trace_bundle(manifest_path) states = replay_trace(bundle.trace) - selected = _selected_indices(bundle.trace.events, max_frames) + selected = select_semantic_milestones(bundle.trace.events, max_frames) frames = tuple( _compose_frame(bundle, bundle.trace.events[index], states[index]) for index in selected diff --git a/renderer/wang_z3_summary.py b/renderer/wang_z3_summary.py index 22fcdea..5989546 100644 --- a/renderer/wang_z3_summary.py +++ b/renderer/wang_z3_summary.py @@ -384,6 +384,123 @@ def _compose_wang_frame(summary: Z3EncodingSummary, stage: int) -> Image.Image: return image +def _compose_boolean_frame(summary: Z3EncodingSummary, stage: int) -> Image.Image: + width, height = 940, 430 + image = Image.new("RGB", (width, height), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(image) + labels = ( + "configuration", + "Boolean variables", + "source-order clauses", + "result, assignment, and encoding statistics", + ) + draw_explain_heading( + draw, + (18, 16), + title="Boolean Z3 encoding order", + subtitle=( + f"encoding-order stage {stage + 1}/4 | {labels[stage]} | " + "not a Z3 internal execution trace" + ), + ) + draw.text( + (18, 78), + f"Z3 {summary.version} | random_seed={summary.random_seed} | " + f"threads={summary.threads}", + font=explain_font(12), + fill=EXPLAIN_TEXT_RGB, + ) + steps = ( + "1. create one Boolean term per variable in ascending ID order", + "2. visit clauses in source order", + "3. visit each clause left-to-right and assert exactly one true", + "4. check once; copy assignment and project-owned statistics", + ) + for index, label in enumerate(steps): + y = 112 + index * 48 + active = index <= stage + draw.rounded_rectangle( + (18, y, 520, y + 34), + radius=5, + fill=(213, 235, 246) if active else (239, 241, 245), + outline=(54, 127, 169) if index == stage else (184, 190, 201), + width=2 if index == stage else 1, + ) + draw.text( + (29, y + 9), + label, + font=explain_font(10), + fill=EXPLAIN_TEXT_RGB if active else EXPLAIN_MUTED_RGB, + ) + + draw.rounded_rectangle( + (548, 106, 922, 378), + radius=6, + fill=EXPLAIN_ACTIVE_RGB, + outline=(180, 187, 198), + ) + if stage == 0: + lines = ( + f"formula variables: {summary.variable_count}", + "fixed seed and one solver thread", + "project order is public; Z3 search order is not", + ) + elif stage == 1: + lines = ( + f"Boolean terms: {summary.variable_count}", + "term IDs follow the formula variable IDs", + "no native-solver state is imported", + ) + elif stage == 2: + lines = ( + f"assertions: {summary.assertion_count}", + "clause order follows the parsed source", + "literal positions remain left-to-right", + ) + else: + assignment = summary.assignment or () + assignment_text = ", ".join( + f"x{index + 1}={'1' if value else '0'}" + for index, value in enumerate(assignment) + ) + lines = ( + f"result: {summary.status.upper()}", + f"assignment: {assignment_text or 'not applicable'}", + f"encoding statistics: {len(summary.statistics)}", + "independent assignment checking is downstream", + ) + y = 128 + for line in lines: + draw.text((566, y), line, font=explain_font(11), fill=EXPLAIN_TEXT_RGB) + y += 31 + draw.text( + (18, 408), + "Rendering explains declared construction order; it does not expose Z3 decisions.", + font=explain_font(9), + fill=EXPLAIN_MUTED_RGB, + ) + return image + + +def render_boolean_z3_assets( + summary_path: str | Path, + output_directory: str | Path, + *, + duration_ms: int = 800, +) -> AnimationOutputs: + summary = load_z3_encoding_summary(summary_path) + if summary.engine != BOOLEAN_ENGINE: + raise WangSquareRenderError("encoding animation requires a Boolean Z3 summary") + frames = tuple(_compose_boolean_frame(summary, stage) for stage in range(4)) + return write_animation_assets( + frames, + tuple(f"frame-{stage:02d}.png" for stage in range(4)), + output_directory, + fallback_index=2, + duration_ms=duration_ms, + ) + + def render_wang_z3_assets( summary_path: str | Path, output_directory: str | Path, @@ -405,7 +522,7 @@ def render_wang_z3_assets( def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="render the declared Wang Z3 encoding order without Z3" + description="render a declared Boolean or Wang Z3 encoding order without Z3" ) parser.add_argument("summary", type=Path) parser.add_argument("output_directory", type=Path) @@ -417,10 +534,14 @@ def main(arguments: list[str] | None = None) -> int: parser = _parser() args = parser.parse_args(arguments) try: - outputs = render_wang_z3_assets( - args.summary, - args.output_directory, - duration_ms=args.duration_ms, + summary = load_z3_encoding_summary(args.summary) + renderer = ( + render_boolean_z3_assets + if summary.engine == BOOLEAN_ENGINE + else render_wang_z3_assets + ) + outputs = renderer( + args.summary, args.output_directory, duration_ms=args.duration_ms ) except (FileNotFoundError, WangSquareRenderError) as error: parser.error(str(error)) diff --git a/schemas/wang-narrative-assets-v1.schema.json b/schemas/wang-narrative-assets-v1.schema.json new file mode 100644 index 0000000..c761047 --- /dev/null +++ b/schemas/wang-narrative-assets-v1.schema.json @@ -0,0 +1,303 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://xtraid.github.io/tiling-foundry/schemas/wang-narrative-assets-v1.schema.json", + "title": "Tiling Foundry shared narrative assets v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "product", + "case", + "identities", + "animations", + "statics", + "pdf_milestones" + ], + "properties": { + "schema": {"const": "wang-narrative-assets-v1"}, + "product": {"enum": ["run-specific", "canonical-pages"]}, + "case": { + "type": "object", + "additionalProperties": false, + "required": ["id", "expected_status", "source_sha256"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "expected_status": {"enum": ["sat", "unsat"]}, + "source_sha256": {"$ref": "#/$defs/sha256"} + } + }, + "identities": { + "type": "object", + "additionalProperties": false, + "required": [ + "source_formula", + "formula_snapshot", + "tileset", + "region", + "provenance", + "boolean_z3_summary", + "reference_trace_manifest", + "reference_trace", + "reference_solution", + "optimized_trace_manifest", + "optimized_trace", + "optimized_solution", + "wang_z3_summary" + ], + "properties": { + "source_formula": {"$ref": "#/$defs/sha256"}, + "formula_snapshot": {"$ref": "#/$defs/sha256"}, + "tileset": {"$ref": "#/$defs/sha256"}, + "region": {"$ref": "#/$defs/sha256"}, + "provenance": {"$ref": "#/$defs/sha256"}, + "boolean_z3_summary": {"$ref": "#/$defs/sha256"}, + "reference_trace_manifest": {"$ref": "#/$defs/sha256"}, + "reference_trace": {"$ref": "#/$defs/sha256"}, + "reference_solution": {"$ref": "#/$defs/nullable_sha256"}, + "optimized_trace_manifest": {"$ref": "#/$defs/sha256"}, + "optimized_trace": {"$ref": "#/$defs/sha256"}, + "optimized_solution": {"$ref": "#/$defs/nullable_sha256"}, + "wang_z3_summary": {"$ref": "#/$defs/sha256"} + } + }, + "animations": { + "type": "object", + "additionalProperties": false, + "required": [ + "pipeline_overview", + "boolean_z3", + "region_construction", + "reference_trace", + "optimized_trace", + "wang_z3", + "verification", + "witness_presentation", + "optimized_mechanisms" + ], + "properties": { + "pipeline_overview": {"$ref": "#/$defs/animation"}, + "boolean_z3": {"$ref": "#/$defs/animation"}, + "region_construction": {"$ref": "#/$defs/animation"}, + "reference_trace": {"$ref": "#/$defs/animation"}, + "optimized_trace": {"$ref": "#/$defs/animation"}, + "wang_z3": {"$ref": "#/$defs/animation"}, + "verification": {"$ref": "#/$defs/animation"}, + "witness_presentation": { + "oneOf": [ + {"$ref": "#/$defs/animation"}, + {"type": "null"} + ] + }, + "optimized_mechanisms": {"$ref": "#/$defs/animation"} + } + }, + "statics": { + "type": "object", + "additionalProperties": false, + "required": [ + "home_preview", + "worked_example", + "formula", + "generalized_sheet", + "atomic_legend", + "square_presentation", + "generalized_presentation", + "hex_presentation", + "presentation_status" + ], + "properties": { + "home_preview": {"$ref": "#/$defs/nullable_static"}, + "worked_example": {"$ref": "#/$defs/nullable_static"}, + "formula": {"$ref": "#/$defs/static"}, + "generalized_sheet": {"$ref": "#/$defs/static"}, + "atomic_legend": {"$ref": "#/$defs/static"}, + "square_presentation": {"$ref": "#/$defs/nullable_static"}, + "generalized_presentation": {"$ref": "#/$defs/nullable_static"}, + "hex_presentation": {"$ref": "#/$defs/nullable_static"}, + "presentation_status": {"$ref": "#/$defs/nullable_static"} + } + }, + "pdf_milestones": { + "type": "object", + "additionalProperties": false, + "required": [ + "selector", + "region_construction", + "reference_trace", + "optimized_trace", + "end_to_end" + ], + "properties": { + "selector": {"const": "semantic-milestones-v1"}, + "region_construction": {"$ref": "#/$defs/png_paths"}, + "reference_trace": {"$ref": "#/$defs/png_paths"}, + "optimized_trace": {"$ref": "#/$defs/png_paths"}, + "end_to_end": {"$ref": "#/$defs/png_paths"} + } + } + }, + "$defs": { + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "nullable_sha256": { + "oneOf": [ + {"$ref": "#/$defs/sha256"}, + {"type": "null"} + ] + }, + "path": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?(?:/|$))[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$", + "minLength": 1 + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "media_type"], + "properties": { + "path": {"$ref": "#/$defs/path"}, + "sha256": {"$ref": "#/$defs/sha256"}, + "media_type": {"enum": ["image/png", "image/gif"]} + } + }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": ["complete", "selected", "truncated"], + "properties": { + "complete": {"type": "boolean"}, + "selected": {"type": "boolean"}, + "truncated": {"const": false} + } + }, + "metadata": { + "type": "object", + "required": [ + "owner", + "semantic_label", + "caption", + "alt_text", + "source_contract", + "source_sha256", + "producer", + "validator", + "compositor" + ], + "properties": { + "owner": { + "type": "string", + "pattern": "^/(?:[A-Za-z0-9._-]+/)*$" + }, + "semantic_label": { + "enum": [ + "observed", + "canonical-construction", + "encoding-order", + "verified-transformation", + "didactic" + ] + }, + "caption": {"type": "string", "minLength": 1}, + "alt_text": {"type": "string", "minLength": 1}, + "source_contract": {"type": "string", "minLength": 1}, + "source_sha256": {"$ref": "#/$defs/sha256"}, + "producer": {"type": "string", "minLength": 1}, + "validator": {"type": "string", "minLength": 1}, + "compositor": {"type": "string", "minLength": 1} + } + }, + "animation": { + "allOf": [ + {"$ref": "#/$defs/metadata"}, + { + "type": "object", + "additionalProperties": false, + "required": [ + "owner", + "semantic_label", + "caption", + "alt_text", + "source_contract", + "source_sha256", + "producer", + "validator", + "compositor", + "scope", + "animation", + "fallback", + "contact_sheet", + "frames" + ], + "properties": { + "owner": true, + "semantic_label": true, + "caption": true, + "alt_text": true, + "source_contract": true, + "source_sha256": true, + "producer": true, + "validator": true, + "compositor": true, + "scope": {"$ref": "#/$defs/scope"}, + "animation": {"$ref": "#/$defs/artifact"}, + "fallback": {"$ref": "#/$defs/artifact"}, + "contact_sheet": {"$ref": "#/$defs/artifact"}, + "frames": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/artifact"} + } + } + } + ] + }, + "static": { + "allOf": [ + {"$ref": "#/$defs/metadata"}, + { + "type": "object", + "additionalProperties": false, + "required": [ + "owner", + "semantic_label", + "caption", + "alt_text", + "source_contract", + "source_sha256", + "producer", + "validator", + "compositor", + "artifact" + ], + "properties": { + "owner": true, + "semantic_label": true, + "caption": true, + "alt_text": true, + "source_contract": true, + "source_sha256": true, + "producer": true, + "validator": true, + "compositor": true, + "artifact": {"$ref": "#/$defs/artifact"} + } + } + ] + }, + "nullable_static": { + "oneOf": [ + {"$ref": "#/$defs/static"}, + {"type": "null"} + ] + }, + "png_paths": { + "type": "array", + "minItems": 1, + "items": { + "allOf": [ + {"$ref": "#/$defs/path"}, + {"pattern": "\\.png$"} + ] + } + } + } +} diff --git a/tests/python/test_multi_engine_dossier.py b/tests/python/test_multi_engine_dossier.py index 2a2c6b8..ce29f0e 100644 --- a/tests/python/test_multi_engine_dossier.py +++ b/tests/python/test_multi_engine_dossier.py @@ -4,6 +4,7 @@ import json import os from pathlib import Path +import re import shutil import subprocess import sys @@ -12,7 +13,9 @@ from unittest.mock import patch from dossier import multi_engine +from dossier import narrative_assets as narrative_generator from formats.pipeline_snapshot import PipelineSnapshotError +from formats.narrative_assets import load_narrative_assets from formats.run_case_v2 import ( CASE_SCHEMA, load_run_case_v2, @@ -33,27 +36,7 @@ ROOT = Path(__file__).resolve().parents[2] SAT_CASE = ROOT / "examples/run-cases-v2/pipeline-sat.json" - - -def _unsat_case() -> dict[str, object]: - return { - "schema": CASE_SCHEMA, - "id": "pipeline-unsat-v2", - "title": "Full-pipeline UNSAT capture test", - "purpose": "Exercise the closed v2 UNSAT and not-applicable fields.", - "source": "tests/instances/pipeline_unsat_search.cm13", - "expected_status": "unsat", - "reference_trace": { - "event_capacity": 8192, - "checkpoint_interval": 128, - "checkpoint_capacity": 64, - }, - "optimized_trace": { - "event_capacity": 8192, - "checkpoint_interval": 128, - "checkpoint_capacity": 64, - }, - } +UNSAT_CASE = ROOT / "examples/run-cases-v2/pipeline-unsat-search.json" class MultiEngineDossierTests(unittest.TestCase): @@ -65,14 +48,9 @@ def setUpClass(cls) -> None: multi_engine.generate_multi_engine_dossier(SAT_CASE, cls.sat_directory) cls.sat_document = load_run_dossier_v2(cls.sat_directory / "run.json") - cls.unsat_case_path = cls.root / "unsat-case.json" - cls.unsat_case_path.write_text( - json.dumps(_unsat_case(), ensure_ascii=False) + "\n", - encoding="utf-8", - ) cls.unsat_directory = cls.root / "unsat" multi_engine.generate_multi_engine_dossier( - cls.unsat_case_path, + UNSAT_CASE, cls.unsat_directory, ) cls.unsat_document = load_run_dossier_v2( @@ -161,7 +139,27 @@ def test_sat_capture_binds_named_engines_and_shared_native_inputs(self) -> None: document["reduction"]["region_sha256"], document["artifacts"]["region_snapshot"]["sha256"], ) - self.assertIsNone(document["presentation"]["square"]["artifact"]) + self.assertEqual( + document["presentation"]["square"]["artifact"], + "square_presentation", + ) + self.assertEqual(document["reference"]["trace"]["selection"], { + "performed": True, + "selected_event_count": 10, + }) + self.assertEqual(document["optimized"]["trace"]["selection"], { + "performed": True, + "selected_event_count": 10, + }) + narrative = load_narrative_assets( + self.sat_directory / "assets/narrative/manifest.json", + document, + ) + self.assertEqual(narrative["product"], "run-specific") + self.assertEqual( + narrative["animations"]["optimized_mechanisms"]["semantic_label"], + "didactic", + ) self.assertFalse((self.sat_directory / "report.tex").exists()) self.assertFalse((self.sat_directory / "report.pdf").exists()) @@ -188,6 +186,36 @@ def test_unsat_capture_has_no_witness_or_fabricated_verification(self) -> None: for presentation in document["presentation"].values(): self.assertFalse(presentation["applicable"]) self.assertIsNone(presentation["artifact"]) + narrative = load_narrative_assets( + self.unsat_directory / "assets/narrative/manifest.json", + document, + ) + self.assertIsNone(narrative["animations"]["witness_presentation"]) + self.assertIsNotNone(narrative["statics"]["presentation_status"]) + self.assertIsNone(narrative["statics"]["home_preview"]) + for solver, animation_name in ( + ("reference", "reference_trace"), + ("optimized", "optimized_trace"), + ): + trace_path = self.unsat_directory / document["artifacts"][ + f"{solver}_trace" + ]["path"] + trace = json.loads(trace_path.read_text(encoding="utf-8")) + all_kinds = {event["kind"] for event in trace["events"]} + self.assertTrue({"decision", "conflict", "backtrack"} <= all_kinds) + selected_sequences = { + int(Path(frame["path"]).stem.removeprefix("frame-")) + for frame in narrative["animations"][animation_name]["frames"] + } + selected_kinds = { + event["kind"] + for event in trace["events"] + if event["sequence"] in selected_sequences + } + self.assertTrue( + {"conflict", "backtrack", "result"} <= selected_kinds, + (solver, selected_kinds), + ) def test_case_contract_forbids_initial_domain_overrides(self) -> None: invalid = json.loads(SAT_CASE.read_text(encoding="utf-8")) @@ -309,6 +337,187 @@ def test_bundle_loader_imports_no_capture_or_native_producer(self) -> None: ) self.assertEqual(completed.returncode, 0, completed.stderr) + def test_same_compositor_bytes_serve_run_and_canonical_pages(self) -> None: + destination = self.root / "canonical-pages-assets" + manifest_path = narrative_generator.generate_narrative_assets( + self.sat_directory / "run.json", + destination, + product="canonical-pages", + ) + manifest = load_narrative_assets(manifest_path, self.sat_document) + self.assertEqual(manifest["product"], "canonical-pages") + + run_root = self.sat_directory / "assets/narrative" + run_files = { + path.relative_to(run_root): path.read_bytes() + for path in run_root.rglob("*") + if path.is_file() and path.name != "manifest.json" + } + pages_files = { + path.relative_to(destination): path.read_bytes() + for path in destination.rglob("*") + if path.is_file() and path.name != "manifest.json" + } + self.assertEqual(run_files, pages_files) + + def test_narrative_manifest_rejects_metadata_and_asset_tampering(self) -> None: + copied = self.root / "narrative-tampered" + shutil.copytree(self.sat_directory, copied) + run = load_run_dossier_v2(copied / "run.json") + manifest_path = copied / "assets/narrative/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["animations"]["reference_trace"]["semantic_label"] = "didactic" + manifest_path.write_text(json.dumps(manifest) + "\n", encoding="utf-8") + with self.assertRaisesRegex(PipelineSnapshotError, "semantic_label"): + load_narrative_assets(manifest_path, run) + + copied = self.root / "narrative-source-tampered" + shutil.copytree(self.sat_directory, copied) + run = load_run_dossier_v2(copied / "run.json") + manifest_path = copied / "assets/narrative/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["statics"]["square_presentation"]["source_sha256"] = "0" * 64 + manifest_path.write_text(json.dumps(manifest) + "\n", encoding="utf-8") + with self.assertRaisesRegex(PipelineSnapshotError, "static source"): + load_narrative_assets(manifest_path, run) + + copied = self.root / "narrative-scope-tampered" + shutil.copytree(self.sat_directory, copied) + run = load_run_dossier_v2(copied / "run.json") + manifest_path = copied / "assets/narrative/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["animations"]["reference_trace"]["scope"]["selected"] = False + manifest_path.write_text(json.dumps(manifest) + "\n", encoding="utf-8") + with self.assertRaisesRegex(PipelineSnapshotError, "selected.*policy"): + load_narrative_assets(manifest_path, run) + + copied = self.root / "narrative-bytes-tampered" + shutil.copytree(self.sat_directory, copied) + manifest_path = copied / "assets/narrative/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + frame = copied / "assets/narrative" / manifest["animations"][ + "boolean_z3" + ]["frames"][0]["path"] + frame.write_bytes(frame.read_bytes() + b"tampered") + with self.assertRaisesRegex(PipelineSnapshotError, "does not match file"): + load_run_dossier_v2(copied / "run.json") + + copied = self.root / "narrative-symlinked" + shutil.copytree(self.sat_directory, copied) + manifest_path = copied / "assets/narrative/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + frame = copied / "assets/narrative" / manifest["animations"][ + "boolean_z3" + ]["frames"][0]["path"] + alias = frame.with_name("internal-alias.png") + alias.write_bytes(frame.read_bytes()) + frame.unlink() + frame.symlink_to(alias.name) + with self.assertRaisesRegex(PipelineSnapshotError, "symlinks are forbidden"): + load_run_dossier_v2(copied / "run.json") + + copied = self.root / "narrative-parent-symlinked" + shutil.copytree(self.sat_directory, copied) + narrative_directory = copied / "assets/narrative" + relocated = copied / "relocated-narrative" + narrative_directory.rename(relocated) + narrative_directory.symlink_to("../relocated-narrative", target_is_directory=True) + with self.assertRaisesRegex(PipelineSnapshotError, "may not contain symlinks"): + load_run_dossier_v2(copied / "run.json") + + def test_narrative_manifest_rejects_toolchain_and_milestone_drift(self) -> None: + copied = self.root / "narrative-toolchain-tampered" + shutil.copytree(self.sat_directory, copied) + run = load_run_dossier_v2(copied / "run.json") + manifest_path = copied / "assets/narrative/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["animations"]["witness_presentation"]["validator"] = "fake.validator" + manifest_path.write_text(json.dumps(manifest) + "\n", encoding="utf-8") + with self.assertRaisesRegex(PipelineSnapshotError, "validator"): + load_narrative_assets(manifest_path, run) + + copied = self.root / "narrative-milestones-tampered" + shutil.copytree(self.sat_directory, copied) + run = load_run_dossier_v2(copied / "run.json") + manifest_path = copied / "assets/narrative/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["pdf_milestones"]["reference_trace"].reverse() + manifest_path.write_text(json.dumps(manifest) + "\n", encoding="utf-8") + with self.assertRaisesRegex(PipelineSnapshotError, "shared frame order"): + load_narrative_assets(manifest_path, run) + + def test_run_loader_rejects_an_unattached_narrative_manifest(self) -> None: + copied = self.root / "narrative-unattached" + shutil.copytree(self.sat_directory, copied) + run_path = copied / "run.json" + run = json.loads(run_path.read_text(encoding="utf-8")) + for solver in ("reference", "optimized"): + run[solver]["trace"]["selection"] = { + "performed": False, + "selected_event_count": None, + } + run_path.write_text(json.dumps(run) + "\n", encoding="utf-8") + with self.assertRaisesRegex(PipelineSnapshotError, "performed selection"): + load_run_dossier_v2(run_path) + + def test_narrative_generation_preserves_existing_destination_and_cleans_failure(self) -> None: + destination = self.root / "existing-narrative" + destination.mkdir() + sentinel = destination / "sentinel" + sentinel.write_text("keep\n", encoding="utf-8") + with self.assertRaisesRegex( + narrative_generator.NarrativeAssetError, + "already exists", + ): + narrative_generator.generate_narrative_assets( + self.sat_directory / "run.json", destination + ) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep\n") + + failed = self.root / "failed-narrative" + before = set(self.root.glob(".failed-narrative.*")) + with patch.object( + narrative_generator, + "_run_renderer", + side_effect=narrative_generator.NarrativeAssetError("forced render failure"), + ): + with self.assertRaisesRegex( + narrative_generator.NarrativeAssetError, + "forced render failure", + ): + narrative_generator.generate_narrative_assets( + self.sat_directory / "run.json", failed + ) + self.assertFalse(failed.exists()) + self.assertEqual(set(self.root.glob(".failed-narrative.*")), before) + + with patch.object( + narrative_generator.shutil, + "which", + return_value="/usr/bin/uv", + ), patch.object( + narrative_generator.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 0, stdout="", stderr=""), + ): + with self.assertRaisesRegex( + narrative_generator.NarrativeAssetError, + "omitted required output keys: fallback", + ): + narrative_generator._run_renderer( + ["renderer.py"], required_outputs=("fallback",) + ) + + with self.assertRaisesRegex( + narrative_generator.NarrativeAssetError, + "canonical pipeline SAT", + ): + narrative_generator.generate_narrative_assets( + self.unsat_directory / "run.json", + self.root / "invalid-canonical-pages", + product="canonical-pages", + ) + def test_failure_cleanup_and_existing_destination_are_safe(self) -> None: destination = self.root / "existing" destination.mkdir() @@ -354,6 +563,27 @@ def disagree(*args: object, **kwargs: object) -> dict[str, object]: self.assertFalse(mismatch.exists()) self.assertEqual(set(self.root.glob(".mismatch.*")), before) + narrative_failure = self.root / "narrative-failure" + before = set(self.root.glob(".narrative-failure.*")) + with patch.object( + narrative_generator, + "generate_narrative_assets", + side_effect=narrative_generator.NarrativeAssetError( + "forced narrative failure" + ), + ): + with self.assertRaisesRegex( + multi_engine.MultiEngineDossierError, + "shared narrative asset pass failed", + ): + multi_engine.generate_multi_engine_dossier( + SAT_CASE, narrative_failure + ) + self.assertFalse(narrative_failure.exists()) + self.assertEqual( + set(self.root.glob(".narrative-failure.*")), before + ) + def test_final_replace_failure_leaves_no_partial_destination(self) -> None: destination = self.root / "replace-failed" before = set(self.root.glob(".replace-failed.*")) @@ -383,6 +613,7 @@ def test_v2_schemas_are_closed_draft_2020_12_documents(self) -> None: for name, expected in ( ("wang-run-case-v2.schema.json", CASE_SCHEMA), ("wang-run-dossier-v2.schema.json", RUN_SCHEMA), + ("wang-narrative-assets-v1.schema.json", "wang-narrative-assets-v1"), ): with self.subTest(schema=name): schema = json.loads((ROOT / "schemas" / name).read_text(encoding="utf-8")) @@ -392,6 +623,29 @@ def test_v2_schemas_are_closed_draft_2020_12_documents(self) -> None: ) self.assertEqual(schema["properties"]["schema"]["const"], expected) self.assertFalse(schema["additionalProperties"]) + if name == "wang-narrative-assets-v1.schema.json": + owner = re.compile( + schema["$defs"]["metadata"]["properties"]["owner"][ + "pattern" + ] + ) + self.assertIsNotNone(owner.fullmatch("/")) + self.assertIsNotNone(owner.fullmatch("/components/verification/")) + self.assertIsNone(owner.fullmatch("components/verification/")) + relative_path = re.compile(schema["$defs"]["path"]["pattern"]) + self.assertIsNotNone(relative_path.fullmatch("frames/frame-00.png")) + for invalid in ( + "/etc/passwd", + "../frame.png", + "frames/../frame.png", + "frames//frame.png", + ): + self.assertIsNone(relative_path.fullmatch(invalid)) + png_path_rules = schema["$defs"]["png_paths"]["items"]["allOf"] + self.assertEqual(png_path_rules[0]["$ref"], "#/$defs/path") + png_suffix = re.compile(png_path_rules[1]["pattern"]) + self.assertIsNotNone(png_suffix.search("frames/frame-00.png")) + self.assertIsNone(png_suffix.search("frames/frame-00.gif")) if __name__ == "__main__": diff --git a/tools/generate_narrative_assets.py b/tools/generate_narrative_assets.py new file mode 100644 index 0000000..984871f --- /dev/null +++ b/tools/generate_narrative_assets.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""CLI for the fixed shared narrative-asset pass.""" + +from __future__ import annotations + +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +from dossier.narrative_assets import main # noqa: E402 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/generate_run_dossier.py b/tools/generate_run_dossier.py index 1ea0226..9a9dddf 100644 --- a/tools/generate_run_dossier.py +++ b/tools/generate_run_dossier.py @@ -462,7 +462,7 @@ def generate_run_dossier( if schema == "wang-run-case-v2": if tex_engine != "pdflatex" or max_frames != 12 or duration_ms != 500: raise DossierGenerationError( - "renderer and TeX options are v1-only until the v2 asset/PDF passes" + "v2 uses fixed asset parameters; configurable renderer and TeX options remain v1-only" ) from dossier.multi_engine import ( MultiEngineDossierError,