From 10df326cbf03264d93d79ab1360976be36c1d374 Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Wed, 26 Aug 2026 19:03:54 +0200 Subject: [PATCH] Add static explainability snapshots and views --- README.md | 68 +- docs/development_principles.md | 14 +- docs/wang_explainability_snapshots.md | 136 + python/formats/pipeline_snapshot.py | 780 ++++ renderer/README.md | 46 +- renderer/UPSTREAM.md | 16 +- renderer/test_data/pipeline_sat_formula.png | Bin 0 -> 30226 bytes .../test_data/pipeline_sat_region_hex.png | Bin 0 -> 59951 bytes .../test_data/pipeline_sat_region_square.png | Bin 0 -> 43898 bytes .../test_data/pipeline_sat_tileset_hex.png | Bin 0 -> 29028 bytes .../test_data/pipeline_sat_tileset_square.png | Bin 0 -> 24923 bytes .../wang_solution_v1_hex_explain.png | Bin 0 -> 21665 bytes .../wang_solution_v1_square_explain.png | Bin 0 -> 15488 bytes renderer/test_wang_snapshot.py | 309 ++ renderer/wang_explain.py | 355 ++ renderer/wang_snapshot.py | 984 ++++++ renderer/wang_square.py | 343 +- schemas/cm13-formula-snapshot-v1.schema.json | 65 + schemas/wang-explain-manifest-v1.schema.json | 77 + schemas/wang-region-snapshot-v1.schema.json | 86 + schemas/wang-tileset-snapshot-v1.schema.json | 70 + ...307fdb5fea0617de3256b717ead1af1d67815.json | 34 + .../pipeline_sat_explain/manifest.json | 22 + ...8a7f2f0bf27d69db3eb653208d08c5826199a.json | 3137 +++++++++++++++++ ...65a6be3bfcf59169f0acd96264831196723fa.json | 237 ++ tests/python/test_pipeline_snapshot.py | 363 ++ tools/export_pipeline_snapshots.py | 56 + 27 files changed, 7147 insertions(+), 51 deletions(-) create mode 100644 docs/wang_explainability_snapshots.md create mode 100644 python/formats/pipeline_snapshot.py create mode 100644 renderer/test_data/pipeline_sat_formula.png create mode 100644 renderer/test_data/pipeline_sat_region_hex.png create mode 100644 renderer/test_data/pipeline_sat_region_square.png create mode 100644 renderer/test_data/pipeline_sat_tileset_hex.png create mode 100644 renderer/test_data/pipeline_sat_tileset_square.png create mode 100644 renderer/test_data/wang_solution_v1_hex_explain.png create mode 100644 renderer/test_data/wang_solution_v1_square_explain.png create mode 100644 renderer/test_wang_snapshot.py create mode 100644 renderer/wang_explain.py create mode 100644 renderer/wang_snapshot.py create mode 100644 schemas/cm13-formula-snapshot-v1.schema.json create mode 100644 schemas/wang-explain-manifest-v1.schema.json create mode 100644 schemas/wang-region-snapshot-v1.schema.json create mode 100644 schemas/wang-tileset-snapshot-v1.schema.json create mode 100644 tests/fixtures/pipeline_sat_explain/formula-5098eb9a85adebf28757b1368e9307fdb5fea0617de3256b717ead1af1d67815.json create mode 100644 tests/fixtures/pipeline_sat_explain/manifest.json create mode 100644 tests/fixtures/pipeline_sat_explain/region-aad4dc7dcb46ed070995bf975248a7f2f0bf27d69db3eb653208d08c5826199a.json create mode 100644 tests/fixtures/pipeline_sat_explain/tileset-462a102690c420bfe52a634e7b365a6be3bfcf59169f0acd96264831196723fa.json create mode 100644 tests/python/test_pipeline_snapshot.py create mode 100644 tools/export_pipeline_snapshots.py diff --git a/README.md b/README.md index 54c9fdd..273168d 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ solving remains future work. | Verified square solution export | Implemented as the closed `wang-solution-v1` contract and deterministic exporter | | Wang diagnostic renderer | Implemented as one presentation-only CLI with byte-stable square default and explicit `--hex` mode in the isolated `renderer/` project | | Square-to-hex presentation port | Implemented as a pure in-memory Basire/Culik mapping with a raster-independent checker; no hex solver, schema, or core model | +| Static explainability snapshots | Implemented for parsed formula, canonical tile sheet, and unassigned region, with hash-bound JSON contracts and square/hex diagnostic views | | 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 | @@ -89,29 +90,32 @@ The implemented paths are: ```text .cm13 --> C parser --> native Formula - | \ - | +--> copied Formula --> Boolean Z3 - | | - | v - | Boolean witness checker + | |\ + | | +--> formula snapshot --> formula view + | +----> copied Formula ----> Boolean Z3 + | | + | v + | Boolean witness checker v Yang–Zhang builder --> Region - | \ - | +--> copied Region + TILESET - | | - | v - native reference/ Wang Z3 - optimized solver | - | v - v Python tiling checker - native verifier - | - copied tiling + Python checker - | - v - wang-solution-v1 --> square PNG (default) - \ - +--> pure hex port/check --> hex PNG (--hex) + | |\ + | | +--> static manifest --> region view + | | + tile sheet + | +----> copied Region + TILESET + | | + | v + native reference/ Wang Z3 + optimized solver | + | v + v Python tiling checker + native verifier + | + copied tiling + Python checker + | + v + wang-solution-v1 --> square PNG (default/explain) + \ + +--> pure hex port/check --> hex PNG (--hex) ``` The witness bridge relates exact Boolean assignments to the variable cells of @@ -144,12 +148,15 @@ diagram. ## Next milestones -Planned work remains separated into independently reviewed changes: +Planned work remains separated into independently reviewed changes. The new +priority is explainability across the deterministic pipeline: -1. evaluate MRV indexing on weakly constrained search and build a separate - hard-UNSAT corpus before making parallelism claims; -2. harden allocation and cleanup failure paths before concurrent execution; -3. define and validate a minimal serial `TaskPlan` before implementing OpenMP. +1. add provenance overlays from formula positions to region structures without + changing the generic `Region` model; +2. define bounded, deterministic event traces for native DFS and explicit + encoding summaries for Z3, then render partial states by replay; +3. publish a compact formula-to-final-image gallery from versioned examples; +4. resume serial MRV and hard-UNSAT evidence before `TaskPlan` and OpenMP work. The implementation follows a deliberately small design rule: each datum has one owner, derived state is computed when needed, and future metadata is not added to @@ -172,7 +179,7 @@ make check The imported renderer remains a separate locked Python project. Its Pillow and NumPy dependencies are not installed by the root project or exercised by -`make check`. Run its 213-test combined suite independently: +`make check`. Run its 234-test combined suite independently: ```sh cd renderer @@ -206,6 +213,9 @@ The [square solution contract](docs/wang_solution_v1.md) includes the producer API for exporting a verified native result before rendering it. The [square-to-hex reference](docs/wang_square_to_hex.md) defines the mapping, inverse proof, checker boundary, and integer axial raster convention. +The [static snapshot contract](docs/wang_explainability_snapshots.md) documents +the real formula-to-region export and the formula, tile-sheet, region, and +opt-in final explainability views. Useful individual targets: @@ -285,11 +295,11 @@ src/verify/ independent tiling verification src/io/ formula parsing and native JSON placeholder python/model/ pure Python data contracts python/native/ C ABI adapters and ownership boundaries -python/formats/ versioned solution validation and deterministic export +python/formats/ versioned solution and static-snapshot validation/export python/crosscheck/ scoped native/Z3 witness orchestration python/oracles/ independent Z3 oracles and witness checks python/hex/ deliberately unused empty hex-core placeholders -renderer/ isolated legacy and square/default, hex/explicit Wang rendering +renderer/ isolated legacy and explainable square/hex Wang rendering tests/ C, Python, and instance regressions benchmarks/ fixed reference corpus and profiling runner docs/ theory and architecture references diff --git a/docs/development_principles.md b/docs/development_principles.md index f19821c..3f34ea6 100644 --- a/docs/development_principles.md +++ b/docs/development_principles.md @@ -95,9 +95,10 @@ A module is implemented when it has: | `python/native` | C ABI adaptation, scoped native lifetimes, and complete copy-out | Solver logic and escaping native pointers | | `python/crosscheck` | Scoped composition of adapters, Z3, and independent checkers | Persistent native state and duplicated reduction semantics | | `python/oracles` | Independent solvers and checkers over pure models | Parsing, filesystem I/O, and reduction construction | -| `python/formats` | Square solution validation and deterministic export | Native lifetimes, solving, and presentation | +| `python/formats` | Solution and static pipeline snapshot validation and deterministic export | Native lifetimes, solving, and presentation | | `renderer/wang_hex_port.py` | Pure square-to-hex mapping, inverse checks, and matching-equivalence checks | Raster geometry, semantic verification, and solver access | -| `renderer/wang_square.py` | Structural presentation projection and square/default or hex/explicit rasterization | Semantic verification and solver access | +| `renderer/wang_snapshot.py` | Strict hash-bound snapshot consumption and static formula, tile-sheet, and region composition | Native access, solving, and semantic verification | +| `renderer/wang_square.py` | One CLI for solution and static views, with square/default or hex/explicit rasterization | Semantic verification and solver access | `include/wang/task_plan.h`, `src/parallel/solver_openmp.c`, and the two modules under `python/hex/` are placeholders. `src/io/json.c` is also a placeholder; @@ -201,6 +202,15 @@ instances, and its tests verify that construction. ### Downstream solution and renderer boundary +Before solving, `python/formats/pipeline_snapshot.py` can project copied +`Formula`, canonical `TILESET`, and unassigned `Region` values into three +closed, versioned documents referenced by a hash-bound manifest. These are +presentation-neutral snapshots: they preserve source identity, ordered formula +positions, positional tile IDs, active cells, bounds, and exposed boundary +constraints without adding fields to the core models. The +[static snapshot contract]({{ '/wang-explainability-snapshots/' | relative_url }}) +defines their failure and rendering behavior. + The native-only solve coordinator copies a verified SAT tiling out of native storage and applies the independent Python Wang checker. The exporter then produces the closed square-only `wang-solution-v1` document. The diff --git a/docs/wang_explainability_snapshots.md b/docs/wang_explainability_snapshots.md new file mode 100644 index 0000000..f43047f --- /dev/null +++ b/docs/wang_explainability_snapshots.md @@ -0,0 +1,136 @@ +--- +layout: page +title: Static pipeline snapshots and explainable Wang views +permalink: /wang-explainability-snapshots/ +description: Versioned formula, tileset, and unassigned-region snapshots consumed by the isolated Wang renderer. +section: Architecture and correctness +document_kind: Data and rendering contract +status: Current implementation +updated: 2026-08-26 +nav_order: 33 +--- + +# Static pipeline snapshots and explainable Wang views + +The pipeline can export its state after parsing and region construction without +running a solver. The isolated renderer turns that state into three diagnostic +views: the parsed formula, the canonical tile sheet, and the unassigned region +with the formula beside it. A verified solution can additionally be rendered +with colored edge bands, tile IDs, emphasized boundary constraints, and a +palette legend. + +These views explain data that already exists at a module boundary. They do not +add presentation fields to `Formula` or `Region`, expose native pointers, or +make a PNG part of the correctness argument. + +## Contracts and identity + +The exporter writes four closed JSON documents: + +| Schema | Meaning | +| --- | --- | +| `cm13-formula-snapshot-v1` | Source basename and digest, ordered clauses, and all three variable positions, including repeats | +| `wang-tileset-snapshot-v1` | Canonical positional tile IDs and their square `N,E,S,W` colors | +| `wang-region-snapshot-v1` | Inclusive bounds, dense active mask, exposed boundary constraints, and no assignment | +| `wang-explain-manifest-v1` | Stage identity plus a basename, schema, and full SHA-256 digest for each artifact | + +The manifest is installed atomically only after its content-addressed artifacts +have been installed. Both the producer and the renderer reject duplicate JSON +members, non-finite numbers, unknown fields, invalid types, path traversal, +hash drift, mismatched formula identity, and region colors absent from the +referenced tileset. + +The committed example was produced from `tests/instances/pipeline_sat.cm13` and +lives in `tests/fixtures/pipeline_sat_explain/`. It is data, not a hand-edited +diagram. + +## Export from the real construction pipeline + +Build the shared native library, then parse and reduce one formula: + +```sh +make shared +uv run --frozen python tools/export_pipeline_snapshots.py \ + tests/instances/pipeline_sat.cm13 \ + /tmp/pipeline-sat-explain/manifest.json +``` + +The command loads the formula and constructs the real Yang–Zhang `Region` +through the scoped native adapter. It prints the manifest and three generated +artifact paths. It performs no solving. + +## Render each static stage + +From `renderer/`, pass the manifest to the existing Wang command and select a +view: + +```sh +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_explain/manifest.json \ + output/formula.png --view formula + +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_explain/manifest.json \ + output/tileset-square.png --view tileset + +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_explain/manifest.json \ + output/region-square.png --view region +``` + +The tile sheet and region also accept the explicit `--hex` flag. Their hex +data is not stored in JSON: the renderer applies the same pure Basire/Culik +port and raster-independent checker used for verified solutions. + +```sh +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_explain/manifest.json \ + output/tileset-hex.png --view tileset --hex + +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_explain/manifest.json \ + output/region-hex.png --view region --hex +``` + +The region view deliberately contains no tiles: pale cells are active +positions, crossed cells are outside the region, and colored bands are exposed +boundary constraints. The adjacent formula panel states exactly which parsed +formula the region simulates. + +## Render the final verified witness + +The default solution image remains byte-for-byte compatible. Explainability is +opt-in: + +```sh +uv run --locked python wang_square.py \ + ../tests/fixtures/wang_solution_v1_square_sat.json \ + output/solution-explain.png --explain + +uv run --locked python wang_square.py \ + ../tests/fixtures/wang_solution_v1_square_sat.json \ + output/solution-explain-hex.png --explain --hex +``` + +Every tile shows its positional ID. Each logical edge color has a deterministic +RGB band, exposed constraints receive a heavier outer stroke, and the legend +retains the numeric color identity. The hex view also labels the fresh +presentation color `kappa = max(C) + 1`. + +## Correctness and current scope + +The static contracts stop at the constructed region. They contain neither +solver events nor a partial assignment. Deterministic event traces, replayed +partial states, and algorithm-specific ordering require a separate trace +contract because native DFS and Z3 do not expose the same kind of step. + +The producer is standard-library-only and depends on copied immutable models. +The renderer independently consumes the JSON without importing `libwang.so`, +native adapters, or Z3. Golden PNGs cover all five static geometry/view pairs +and both explainable final-solution modes. The old square and hex solution +goldens remain unchanged when `--explain` is absent. + +Rendering still does not prove SAT, region validity, or witness validity. Those +obligations remain with construction tests, the independent verifier, the +solution exporter, and the square-to-hex checker described by their respective +contracts. diff --git a/python/formats/pipeline_snapshot.py b/python/formats/pipeline_snapshot.py new file mode 100644 index 0000000..b4e1f28 --- /dev/null +++ b/python/formats/pipeline_snapshot.py @@ -0,0 +1,780 @@ +"""Versioned static snapshots for explainable Wang pipeline stages. + +The producer is standard-library-only. It converts the immutable Python +formula, tileset, and region models into closed JSON documents and installs a +manifest only after all content-addressed artifacts have been written. The +snapshots carry presentation-neutral semantics; raster layout remains the +isolated renderer's responsibility. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +from typing import Final + +from model.formula import Formula +from model.region import Region +from model.tileset import COLOR_NONE, TILESET, Tileset + + +FORMULA_SCHEMA: Final = "cm13-formula-snapshot-v1" +TILESET_SCHEMA: Final = "wang-tileset-snapshot-v1" +REGION_SCHEMA: Final = "wang-region-snapshot-v1" +MANIFEST_SCHEMA: Final = "wang-explain-manifest-v1" +GEOMETRY: Final = "square" +STAGE: Final = "region" +DIRECTIONS: Final = ("N", "E", "S", "W") +_OFFSETS: Final = ((0, -1), (1, 0), (0, 1), (-1, 0)) +_SHA256_PATTERN: Final = re.compile(r"[0-9a-f]{64}") + + +class PipelineSnapshotError(ValueError): + """Raised when a static snapshot is malformed or cannot be installed.""" + + +def _fail(path: str, message: str) -> None: + raise PipelineSnapshotError(f"{path}: {message}") + + +def _require_object(value: object, path: str) -> dict[str, object]: + if type(value) is not dict: + _fail(path, "must be an object") + return value + + +def _require_array(value: object, path: str) -> list[object]: + if type(value) is not list: + _fail(path, "must be an array") + return value + + +def _require_string(value: object, path: str) -> str: + if type(value) is not str: + _fail(path, "must be a string") + try: + value.encode("utf-8") + except UnicodeEncodeError as error: + raise PipelineSnapshotError( + f"{path}: must contain valid UTF-8 text" + ) from error + return value + + +def _require_integer( + value: object, + path: str, + *, + nonnegative: bool, +) -> int: + if type(value) is not int: + _fail(path, "must be an integer") + if nonnegative and value < 0: + _fail(path, "must be nonnegative") + return value + + +def _require_exact_fields( + value: dict[str, object], + expected: frozenset[str], + path: str, +) -> None: + if any(type(key) is not str for key in value): + _fail(path, "object member names must be strings") + actual = frozenset(value) + missing = sorted(expected - actual) + extra = sorted(actual - expected) + if missing: + _fail(path, f"missing fields: {', '.join(missing)}") + if extra: + _fail(path, f"unknown fields: {', '.join(extra)}") + + +def _require_literal( + value: object, + expected: str, + path: str, +) -> None: + if _require_string(value, path) != expected: + _fail(path, f"must equal {expected!r}") + + +def _require_sha256(value: object, path: str) -> str: + digest = _require_string(value, path) + if _SHA256_PATTERN.fullmatch(digest) is None: + _fail(path, "must be a lowercase SHA-256 digest") + return digest + + +def _require_artifact_name(value: object, path: str) -> str: + name = _require_string(value, path) + if ( + not name + or name in {".", ".."} + or Path(name).name != name + or "/" in name + or "\\" in name + ): + _fail(path, "must be a nonempty artifact basename") + return name + + +def _validate_edges( + value: object, + path: str, + *, + optional: bool, +) -> tuple[int | None, int | None, int | None, int | None]: + edges = _require_object(value, path) + _require_exact_fields(edges, frozenset(DIRECTIONS), path) + checked: list[int | None] = [] + for direction in DIRECTIONS: + color = edges[direction] + if optional and color is None: + checked.append(None) + else: + checked.append( + _require_integer( + color, + f"{path}.{direction}", + nonnegative=True, + ) + ) + return tuple(checked) + + +def validate_formula_snapshot(document: object) -> None: + """Validate one closed parsed-formula snapshot.""" + root = _require_object(document, "$") + _require_exact_fields( + root, + frozenset({"schema", "source", "variable_count", "clauses"}), + "$", + ) + _require_literal(root["schema"], FORMULA_SCHEMA, "$.schema") + + source = _require_object(root["source"], "$.source") + _require_exact_fields(source, frozenset({"name", "sha256"}), "$.source") + _require_artifact_name(source["name"], "$.source.name") + _require_sha256(source["sha256"], "$.source.sha256") + + variable_count = _require_integer( + root["variable_count"], + "$.variable_count", + nonnegative=True, + ) + if variable_count == 0: + _fail("$.variable_count", "must be positive") + + clauses = _require_array(root["clauses"], "$.clauses") + if len(clauses) != variable_count: + _fail( + "$.clauses", + "length must equal variable_count for a cubic CM1-in-3 formula", + ) + occurrences = [0] * variable_count + for clause_id, raw_clause in enumerate(clauses): + path = f"$.clauses[{clause_id}]" + clause = _require_object(raw_clause, path) + _require_exact_fields( + clause, + frozenset({"clause_id", "variables"}), + path, + ) + actual_id = _require_integer( + clause["clause_id"], + f"{path}.clause_id", + nonnegative=True, + ) + if actual_id != clause_id: + _fail( + f"{path}.clause_id", + f"must equal canonical position {clause_id}", + ) + variables = _require_array(clause["variables"], f"{path}.variables") + if len(variables) != 3: + _fail(f"{path}.variables", "must preserve exactly three positions") + for position, raw_variable in enumerate(variables): + variable = _require_integer( + raw_variable, + f"{path}.variables[{position}]", + nonnegative=True, + ) + if variable >= variable_count: + _fail( + f"{path}.variables[{position}]", + "is outside the canonical variable domain", + ) + occurrences[variable] += 1 + if any(count != 3 for count in occurrences): + _fail("$.clauses", "every variable must occur exactly three times") + + +def validate_tileset_snapshot(document: object) -> None: + """Validate one canonical square-tileset snapshot.""" + root = _require_object(document, "$") + _require_exact_fields( + root, + frozenset({"schema", "geometry", "directions", "colors", "tiles"}), + "$", + ) + _require_literal(root["schema"], TILESET_SCHEMA, "$.schema") + _require_literal(root["geometry"], GEOMETRY, "$.geometry") + + directions = _require_array(root["directions"], "$.directions") + if directions != list(DIRECTIONS): + _fail("$.directions", f"must equal {list(DIRECTIONS)!r}") + + colors = _require_array(root["colors"], "$.colors") + checked_colors = [ + _require_integer(color, f"$.colors[{index}]", nonnegative=True) + for index, color in enumerate(colors) + ] + if not checked_colors: + _fail("$.colors", "must not be empty") + if checked_colors != sorted(set(checked_colors)): + _fail("$.colors", "must be strictly increasing and unique") + + tiles = _require_array(root["tiles"], "$.tiles") + if not tiles: + _fail("$.tiles", "must not be empty") + used_colors: set[int] = set() + for expected_id, raw_tile in enumerate(tiles): + path = f"$.tiles[{expected_id}]" + tile = _require_object(raw_tile, path) + _require_exact_fields(tile, frozenset({"tile_id", "edges"}), path) + tile_id = _require_integer( + tile["tile_id"], + f"{path}.tile_id", + nonnegative=True, + ) + if tile_id != expected_id: + _fail( + f"{path}.tile_id", + f"must equal canonical table position {expected_id}", + ) + used_colors.update( + _validate_edges( + tile["edges"], + f"{path}.edges", + optional=False, + ) + ) + if checked_colors != sorted(used_colors): + _fail("$.colors", "must equal exactly the colors used by the tile table") + + +def validate_region_snapshot(document: object) -> None: + """Validate square-region geometry and exposed boundary constraints.""" + root = _require_object(document, "$") + _require_exact_fields( + root, + frozenset( + { + "schema", + "geometry", + "source_formula_sha256", + "bounds", + "active", + "boundary", + } + ), + "$", + ) + _require_literal(root["schema"], REGION_SCHEMA, "$.schema") + _require_literal(root["geometry"], GEOMETRY, "$.geometry") + _require_sha256(root["source_formula_sha256"], "$.source_formula_sha256") + + bounds = _require_object(root["bounds"], "$.bounds") + bound_fields = frozenset( + { + "min_x_inclusive", + "min_y_inclusive", + "max_x_inclusive", + "max_y_inclusive", + } + ) + _require_exact_fields(bounds, bound_fields, "$.bounds") + min_x = _require_integer( + bounds["min_x_inclusive"], + "$.bounds.min_x_inclusive", + nonnegative=False, + ) + min_y = _require_integer( + bounds["min_y_inclusive"], + "$.bounds.min_y_inclusive", + nonnegative=False, + ) + max_x = _require_integer( + bounds["max_x_inclusive"], + "$.bounds.max_x_inclusive", + nonnegative=False, + ) + max_y = _require_integer( + bounds["max_y_inclusive"], + "$.bounds.max_y_inclusive", + nonnegative=False, + ) + if max_x < min_x or max_y < min_y: + _fail("$.bounds", "inclusive maxima must not precede minima") + width = max_x - min_x + 1 + height = max_y - min_y + 1 + area = width * height + + active = _require_array(root["active"], "$.active") + boundary = _require_array(root["boundary"], "$.boundary") + if len(active) != area: + _fail("$.active", f"length must equal inclusive bounds area {area}") + if len(boundary) != area: + _fail("$.boundary", f"length must equal inclusive bounds area {area}") + if any(type(value) is not bool for value in active): + _fail("$.active", "must contain only booleans") + if not any(active): + _fail("$.active", "must contain at least one active cell") + + checked_boundary: list[ + tuple[int | None, int | None, int | None, int | None] | None + ] = [] + for index, raw_sides in enumerate(boundary): + if not active[index]: + if raw_sides is not None: + _fail( + f"$.boundary[{index}]", + "must be null for an inactive position", + ) + checked_boundary.append(None) + continue + if raw_sides is None: + _fail( + f"$.boundary[{index}]", + "must contain N/E/S/W entries for an active cell", + ) + checked_boundary.append( + _validate_edges( + raw_sides, + f"$.boundary[{index}]", + optional=True, + ) + ) + + for index, is_active in enumerate(active): + if not is_active: + continue + sides = checked_boundary[index] + assert sides is not None + x = index % width + y = index // width + for direction, (dx, dy) in enumerate(_OFFSETS): + neighbor_x = x + dx + neighbor_y = y + dy + neighbor_active = False + if 0 <= neighbor_x < width and 0 <= neighbor_y < height: + neighbor_active = active[neighbor_y * width + neighbor_x] + if neighbor_active and sides[direction] is not None: + _fail( + f"$.boundary[{index}].{DIRECTIONS[direction]}", + "must be null on an edge shared by active cells", + ) + + +def _validate_reference( + value: object, + path: str, + *, + expected_schema: str, +) -> tuple[str, str]: + reference = _require_object(value, path) + _require_exact_fields( + reference, + frozenset({"path", "sha256", "schema"}), + path, + ) + name = _require_artifact_name(reference["path"], f"{path}.path") + digest = _require_sha256(reference["sha256"], f"{path}.sha256") + _require_literal(reference["schema"], expected_schema, f"{path}.schema") + return name, digest + + +def validate_explain_manifest(document: object) -> None: + """Validate the closed manifest structure without loading artifacts.""" + root = _require_object(document, "$") + _require_exact_fields( + root, + frozenset({"schema", "stage", "source_formula_sha256", "artifacts"}), + "$", + ) + _require_literal(root["schema"], MANIFEST_SCHEMA, "$.schema") + _require_literal(root["stage"], STAGE, "$.stage") + _require_sha256(root["source_formula_sha256"], "$.source_formula_sha256") + artifacts = _require_object(root["artifacts"], "$.artifacts") + _require_exact_fields( + artifacts, + frozenset({"formula", "tileset", "region"}), + "$.artifacts", + ) + for name, schema in ( + ("formula", FORMULA_SCHEMA), + ("tileset", TILESET_SCHEMA), + ("region", REGION_SCHEMA), + ): + _validate_reference( + artifacts[name], + f"$.artifacts.{name}", + expected_schema=schema, + ) + + +def _reject_duplicate_members( + pairs: list[tuple[str, object]], +) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise PipelineSnapshotError( + f"JSON object contains duplicate member {key!r}" + ) + result[key] = value + return result + + +def _reject_nonfinite_constant(value: str) -> None: + raise PipelineSnapshotError( + f"JSON document contains non-finite number {value}" + ) + + +def _load_json_bytes(encoded: bytes, label: str) -> dict[str, object]: + try: + decoded = encoded.decode("utf-8") + except UnicodeDecodeError as error: + raise PipelineSnapshotError(f"{label}: is not valid UTF-8") from error + try: + document = json.loads( + decoded, + object_pairs_hook=_reject_duplicate_members, + parse_constant=_reject_nonfinite_constant, + ) + except json.JSONDecodeError as error: + raise PipelineSnapshotError( + f"{label}: invalid JSON at line {error.lineno}, " + f"column {error.colno}: {error.msg}" + ) from error + except (ValueError, RecursionError) as error: + if isinstance(error, PipelineSnapshotError): + raise + raise PipelineSnapshotError(f"{label}: invalid JSON value: {error}") from error + return _require_object(document, label) + + +def load_pipeline_snapshot(path: str | Path) -> dict[str, object]: + """Strictly load and validate any static snapshot document.""" + source = Path(path) + try: + encoded = source.read_bytes() + except OSError as error: + raise PipelineSnapshotError( + f"cannot read snapshot {source!s}: {error}" + ) from error + document = _load_json_bytes(encoded, str(source)) + schema = document.get("schema") + if type(schema) is not str: + _fail("$.schema", "must be a string") + validators = { + FORMULA_SCHEMA: validate_formula_snapshot, + TILESET_SCHEMA: validate_tileset_snapshot, + REGION_SCHEMA: validate_region_snapshot, + MANIFEST_SCHEMA: validate_explain_manifest, + } + validator = validators.get(schema) + if validator is None: + _fail("$.schema", "is not a supported static snapshot schema") + validator(document) + return document + + +def load_explainability_bundle( + manifest_path: str | Path, +) -> tuple[ + dict[str, object], + dict[str, object], + dict[str, object], + dict[str, object], +]: + """Load a manifest and its three hash-bound artifacts.""" + path = Path(manifest_path) + manifest = load_pipeline_snapshot(path) + if manifest["schema"] != MANIFEST_SCHEMA: + _fail("$.schema", f"must equal {MANIFEST_SCHEMA!r}") + artifacts = _require_object(manifest["artifacts"], "$.artifacts") + loaded: dict[str, dict[str, object]] = {} + for name, schema in ( + ("formula", FORMULA_SCHEMA), + ("tileset", TILESET_SCHEMA), + ("region", REGION_SCHEMA), + ): + reference = _require_object(artifacts[name], f"$.artifacts.{name}") + artifact_name, expected_digest = _validate_reference( + reference, + f"$.artifacts.{name}", + expected_schema=schema, + ) + artifact_path = path.parent / artifact_name + try: + encoded = artifact_path.read_bytes() + except OSError as error: + raise PipelineSnapshotError( + f"cannot read {name} artifact {artifact_path!s}: {error}" + ) from error + actual_digest = hashlib.sha256(encoded).hexdigest() + if actual_digest != expected_digest: + _fail( + f"$.artifacts.{name}.sha256", + f"does not match {artifact_name}", + ) + document = _load_json_bytes(encoded, str(artifact_path)) + if document.get("schema") != schema: + _fail(f"$.artifacts.{name}.schema", "does not match artifact") + { + FORMULA_SCHEMA: validate_formula_snapshot, + TILESET_SCHEMA: validate_tileset_snapshot, + REGION_SCHEMA: validate_region_snapshot, + }[schema](document) + loaded[name] = document + + formula = loaded["formula"] + region = loaded["region"] + tileset = loaded["tileset"] + formula_source = _require_object(formula["source"], "formula.source") + source_digest = _require_sha256( + manifest["source_formula_sha256"], + "$.source_formula_sha256", + ) + if formula_source["sha256"] != source_digest: + _fail("$.source_formula_sha256", "does not match formula artifact") + if region["source_formula_sha256"] != source_digest: + _fail("$.source_formula_sha256", "does not match region artifact") + + colors = set(_require_array(tileset["colors"], "tileset.colors")) + for index, raw_sides in enumerate( + _require_array(region["boundary"], "region.boundary") + ): + if raw_sides is None: + continue + sides = _require_object(raw_sides, f"region.boundary[{index}]") + for direction in DIRECTIONS: + color = sides[direction] + if color is not None and color not in colors: + _fail( + f"region.boundary[{index}].{direction}", + "is absent from the referenced tileset color set", + ) + return manifest, formula, tileset, region + + +def build_formula_snapshot( + formula: Formula, + *, + source_name: str, + source_sha256: str, +) -> dict[str, object]: + """Build one parsed-formula snapshot from an immutable model.""" + if not isinstance(formula, Formula): + raise TypeError("formula must be a Formula") + _require_artifact_name(source_name, "source_name") + _require_sha256(source_sha256, "source_sha256") + document: dict[str, object] = { + "schema": FORMULA_SCHEMA, + "source": {"name": source_name, "sha256": source_sha256}, + "variable_count": formula.variable_count, + "clauses": [ + {"clause_id": clause_id, "variables": list(clause)} + for clause_id, clause in enumerate(formula.clauses) + ], + } + validate_formula_snapshot(document) + return document + + +def build_tileset_snapshot( + tileset: Tileset = TILESET, +) -> dict[str, object]: + """Build one positional square-tileset snapshot.""" + if type(tileset) is not tuple or not tileset: + raise TypeError("tileset must be a nonempty tuple") + if any( + type(tile) is not tuple + or len(tile) != len(DIRECTIONS) + or any(type(color) is not int or color < 0 for color in tile) + for tile in tileset + ): + raise TypeError( + "every tile must be an immutable N/E/S/W tuple of " + "nonnegative integer colors" + ) + document: dict[str, object] = { + "schema": TILESET_SCHEMA, + "geometry": GEOMETRY, + "directions": list(DIRECTIONS), + "colors": sorted({color for tile in tileset for color in tile}), + "tiles": [ + { + "tile_id": tile_id, + "edges": { + direction: tile[direction_index] + for direction_index, direction in enumerate(DIRECTIONS) + }, + } + for tile_id, tile in enumerate(tileset) + ], + } + validate_tileset_snapshot(document) + return document + + +def build_region_snapshot( + region: Region, + *, + source_formula_sha256: str, + origin: tuple[int, int] = (0, 0), +) -> dict[str, object]: + """Build one unassigned square-region snapshot.""" + if not isinstance(region, Region): + raise TypeError("region must be a Region") + _require_sha256(source_formula_sha256, "source_formula_sha256") + if ( + type(origin) is not tuple + or len(origin) != 2 + or any(type(coordinate) is not int for coordinate in origin) + ): + raise PipelineSnapshotError( + "origin must be an immutable pair of integer coordinates" + ) + min_x, min_y = origin + boundary = [ + None + if not active + else { + direction: None if color == COLOR_NONE else color + for direction, color in zip(DIRECTIONS, sides, strict=True) + } + for active, sides in zip(region.active, region.boundary, strict=True) + ] + document: dict[str, object] = { + "schema": REGION_SCHEMA, + "geometry": GEOMETRY, + "source_formula_sha256": source_formula_sha256, + "bounds": { + "min_x_inclusive": min_x, + "min_y_inclusive": min_y, + "max_x_inclusive": min_x + region.width - 1, + "max_y_inclusive": min_y + region.height - 1, + }, + "active": list(region.active), + "boundary": boundary, + } + validate_region_snapshot(document) + return document + + +def _encode_document(document: dict[str, object]) -> bytes: + serialized = json.dumps( + document, + ensure_ascii=False, + allow_nan=False, + indent=2, + ) + return f"{serialized}\n".encode("utf-8") + + +def _write_atomic(path: Path, encoded: bytes) -> None: + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as stream: + temporary = Path(stream.name) + stream.write(encoded) + os.replace(temporary, path) + temporary = None + except OSError as error: + raise PipelineSnapshotError( + f"cannot install snapshot {path!s}: {error}" + ) from error + finally: + if temporary is not None: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def dump_pipeline_snapshots( + manifest_path: str | Path, + source_path: str | Path, + formula: Formula, + region: Region, + *, + origin: tuple[int, int] = (0, 0), +) -> Path: + """Write content-addressed artifacts, then atomically install a manifest.""" + destination = Path(manifest_path) + source = Path(source_path) + try: + source_bytes = source.read_bytes() + except OSError as error: + raise PipelineSnapshotError( + f"cannot read formula source {source!s}: {error}" + ) from error + source_digest = hashlib.sha256(source_bytes).hexdigest() + formula_document = build_formula_snapshot( + formula, + source_name=source.name, + source_sha256=source_digest, + ) + tileset_document = build_tileset_snapshot() + region_document = build_region_snapshot( + region, + source_formula_sha256=source_digest, + origin=origin, + ) + documents = { + "formula": (FORMULA_SCHEMA, formula_document), + "tileset": (TILESET_SCHEMA, tileset_document), + "region": (REGION_SCHEMA, region_document), + } + references: dict[str, object] = {} + for name, (schema, document) in documents.items(): + encoded = _encode_document(document) + digest = hashlib.sha256(encoded).hexdigest() + artifact_name = f"{name}-{digest}.json" + if artifact_name == destination.name: + raise PipelineSnapshotError( + "manifest filename must not collide with a generated " + f"artifact: {artifact_name}" + ) + artifact_path = destination.parent / artifact_name + _write_atomic(artifact_path, encoded) + references[name] = { + "path": artifact_name, + "sha256": digest, + "schema": schema, + } + + manifest: dict[str, object] = { + "schema": MANIFEST_SCHEMA, + "stage": STAGE, + "source_formula_sha256": source_digest, + "artifacts": references, + } + validate_explain_manifest(manifest) + _write_atomic(destination, _encode_document(manifest)) + load_explainability_bundle(destination) + return destination diff --git a/renderer/README.md b/renderer/README.md index 97b2978..92edf95 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -82,6 +82,42 @@ byte-for-byte unchanged. A successful PNG is presentation, not a proof; correctness remains the responsibility of the independent Tiling Foundry verifier before export. +Add `--explain` to a verified solution to show a neutral tile center, its +positional tile ID, colored `N,E,S,W` (or six hex) edge bands, emphasized +exposed boundary constraints, and the numeric palette legend: + +```bash +uv run --locked python wang_square.py \ + ../tests/fixtures/wang_solution_v1_square_sat.json \ + output/wang-square-explain.png \ + --explain +``` + +Static pre-solver views consume a `wang-explain-manifest-v1` instead of a +solution. They are inherently explanatory, so they do not use `--explain`: + +```bash +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_explain/manifest.json \ + output/formula.png \ + --view formula + +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_explain/manifest.json \ + output/tileset.png \ + --view tileset + +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_explain/manifest.json \ + output/region.png \ + --view region +``` + +`--view tileset` and `--view region` also accept `--hex`. Both invoke the pure +square-to-hex reducer and checker before rasterization. Formula view rejects +`--hex`; region view intentionally contains no assignment or partial solver +state. + ## Input Format ### palette.json @@ -228,8 +264,11 @@ Raises `RenderingException` on pipeline errors. ├── tests.py # Test suite (144 tests, all passing) ├── wang_square.py # One Wang CLI: square default, explicit hex raster ├── wang_hex_port.py # Pure square-to-hex reducer and independent checker +├── wang_snapshot.py # Strict static-snapshot consumer and view compositor +├── wang_explain.py # Shared colored-edge and legend drawing primitives ├── 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 ├── input/ # Example input files (palette, scene, tiles, sprites) ├── output/ # Rendered PNG output goes here ├── test_data/ @@ -250,9 +289,10 @@ Raises `RenderingException` on pipeline errors. uv run --locked pytest -q ``` -The complete isolated suite has 213 tests: the 144 preserved legacy tests -below, 45 Wang square tests, and 24 square-to-hex/hex-raster tests. To run only -the original upstream suite: +The complete isolated suite has 234 tests: the 144 preserved legacy tests +below, 45 Wang square tests, 24 square-to-hex/hex-raster tests, and 21 static +snapshot/explainability tests. +To run only the original upstream suite: ```bash uv run --locked pytest tests.py -v diff --git a/renderer/UPSTREAM.md b/renderer/UPSTREAM.md index ebacbd5..c488675 100644 --- a/renderer/UPSTREAM.md +++ b/renderer/UPSTREAM.md @@ -21,10 +21,11 @@ tests. The overall and affected section totals now say 144, 18 Palette, 33 SceneParser, and 49 Blitter tests. This `UPSTREAM.md` file is local provenance, not an upstream file. -Tiling Foundry later added `wang_square.py`, `wang_hex_port.py`, their two test -modules, and square/hex goldens under `test_data/`, plus clearly separated -usage notes in `README.md`. These additions consume `wang-solution-v1` as -presentation data and do not modify or import the legacy PAP modules. They +Tiling Foundry later added its isolated Wang modules, tests, and goldens under +`test_data/`, plus clearly separated usage notes in `README.md`. The default +and explainable solution paths consume `wang-solution-v1`; the static formula, +tile-sheet, and unassigned-region views consume a hash-bound explainability +manifest. These additions do not modify or import the legacy PAP modules. They also leave `pyproject.toml`, `uv.lock`, and the pinned dependencies unchanged. ## Verification @@ -42,9 +43,10 @@ uv run --locked pytest -q ``` The import baseline remains 144 collected tests and 144 passing tests. The -combined local suite is 213 tests: 144 preserved legacy tests, 45 Wang square -tests, and 24 square-to-hex/hex-raster tests. Keep the renderer environment -under `renderer/.venv`; its local `.gitignore` excludes that environment and +combined local suite is 234 tests: 144 preserved legacy tests, 45 Wang square +tests, 24 square-to-hex/hex-raster tests, and 21 static +snapshot/explainability tests. Keep the renderer environment under +`renderer/.venv`; its local `.gitignore` excludes that environment and generated Python/build files. The preserved `renderer/.github/workflows/ci.yml` is nested upstream evidence; diff --git a/renderer/test_data/pipeline_sat_formula.png b/renderer/test_data/pipeline_sat_formula.png new file mode 100644 index 0000000000000000000000000000000000000000..b7592adf82c0b95cbb2f1963c599bcec34503ccb GIT binary patch literal 30226 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 FzW_Rc+XDaq literal 0 HcmV?d00001 diff --git a/renderer/test_data/pipeline_sat_region_hex.png b/renderer/test_data/pipeline_sat_region_hex.png new file mode 100644 index 0000000000000000000000000000000000000000..9ecfedc2b39c43e80fbb148a9d24cb513dc48352 GIT binary patch literal 59951 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 KS|aoG^?w1-X)784 literal 0 HcmV?d00001 diff --git a/renderer/test_data/wang_solution_v1_hex_explain.png b/renderer/test_data/wang_solution_v1_hex_explain.png new file mode 100644 index 0000000000000000000000000000000000000000..0c69d56807aba1efaf642ebf0e6a098209b895fb GIT binary patch literal 21665 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 literal 0 HcmV?d00001 diff --git a/renderer/test_data/wang_solution_v1_square_explain.png b/renderer/test_data/wang_solution_v1_square_explain.png new file mode 100644 index 0000000000000000000000000000000000000000..11fab850ac461c70cdbea2727e56e3c282b72049 GIT binary patch literal 15488 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 ImageFont.FreeTypeFont | ImageFont.ImageFont: + """Return Pillow's bundled deterministic font at an explicit size.""" + return ImageFont.load_default(size=max(8, size)) + + +def contrasting_text(rgb: tuple[int, int, int]) -> tuple[int, int, int]: + """Choose black or white text from integer relative luminance.""" + red, green, blue = rgb + luminance = 299 * red + 587 * green + 114 * blue + return (20, 22, 28) if luminance >= 145_000 else (250, 250, 250) + + +def centered_text( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], + text: str, + *, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont, + fill: tuple[int, int, int] = EXPLAIN_TEXT_RGB, +) -> None: + """Draw text centered in an inclusive integer box.""" + left, top, right, bottom = box + text_box = draw.textbbox((0, 0), text, font=font) + width = text_box[2] - text_box[0] + height = text_box[3] - text_box[1] + x = left + (right - left + 1 - width) // 2 - text_box[0] + y = top + (bottom - top + 1 - height) // 2 - text_box[1] + draw.text((x, y), text, font=font, fill=fill) + + +def draw_explain_heading( + draw: ImageDraw.ImageDraw, + origin: tuple[int, int], + *, + title: str, + subtitle: str, +) -> None: + """Draw the common two-line heading used by explainability views.""" + x, y = origin + draw.text((x, y), title, font=explain_font(20), fill=EXPLAIN_TEXT_RGB) + draw.text( + (x, y + 28), + subtitle, + font=explain_font(12), + fill=EXPLAIN_MUTED_RGB, + ) + + +def draw_inactive_key( + draw: ImageDraw.ImageDraw, + origin: tuple[int, int], +) -> None: + """Draw the shared legend key for an inactive bounding-box position.""" + x, y = origin + size = 20 + draw.rectangle( + (x, y, x + size, y + size), + fill=EXPLAIN_INACTIVE_LIGHT_RGB, + outline=EXPLAIN_INACTIVE_DARK_RGB, + ) + draw.line( + (x + 2, y + 2, x + size - 2, y + size - 2), + fill=EXPLAIN_TEXT_RGB, + ) + draw.line( + (x + size - 2, y + 2, x + 2, y + size - 2), + fill=EXPLAIN_TEXT_RGB, + ) + draw.text( + (x + size + 8, y + 1), + "inactive / outside region", + font=explain_font(12), + fill=EXPLAIN_TEXT_RGB, + ) + + +def square_explain_tile( + edges: tuple[int, int, int, int], + palette: Mapping[int, tuple[int, int, int]], + size: int, + *, + tile_id: int | None, + edge_labels: bool, +) -> Image.Image: + """Draw a neutral square face surrounded by four logical-color bands.""" + last = size - 1 + band = max(3, size // 9) + inner = band + asset = Image.new("RGB", (size, size), EXPLAIN_FACE_RGB) + draw = ImageDraw.Draw(asset) + polygons = ( + ((0, 0), (last, 0), (last - inner, inner), (inner, inner)), + ((last, 0), (last, last), (last - inner, last - inner), (last - inner, inner)), + ((last, last), (0, last), (inner, last - inner), (last - inner, last - inner)), + ((0, last), (0, 0), (inner, inner), (inner, last - inner)), + ) + for direction, color in enumerate(edges): + draw.polygon(polygons[direction], fill=palette[color]) + draw.rectangle( + (inner, inner, last - inner, last - inner), + outline=EXPLAIN_OUTLINE_RGB, + width=1, + ) + + if tile_id is not None: + centered_text( + draw, + (inner + 1, inner + 1, last - inner - 1, last - inner - 1), + str(tile_id), + font=explain_font(max(9, size // 4)), + ) + if edge_labels and size >= 56: + font = explain_font(max(8, size // 9)) + labels = ( + (str(edges[0]), (size // 2, band // 2)), + (str(edges[1]), (last - band // 2, size // 2)), + (str(edges[2]), (size // 2, last - band // 2)), + (str(edges[3]), (band // 2, size // 2)), + ) + for direction, (label, center) in enumerate(labels): + text_box = draw.textbbox((0, 0), label, font=font) + width = text_box[2] - text_box[0] + height = text_box[3] - text_box[1] + draw.text( + ( + center[0] - width // 2 - text_box[0], + center[1] - height // 2 - text_box[1], + ), + label, + font=font, + fill=contrasting_text(palette[edges[direction]]), + ) + return asset + + +def square_inactive_tile(size: int) -> Image.Image: + """Draw an explicitly inactive bounding-box position.""" + coordinates = np.arange(size, dtype=np.int32) + x = np.broadcast_to(coordinates, (size, size)) + y = x.T + checker_size = max(2, size // 6) + checker = ((x // checker_size) + (y // checker_size)) % 2 + colors = np.asarray( + (EXPLAIN_INACTIVE_LIGHT_RGB, EXPLAIN_INACTIVE_DARK_RGB), + dtype=np.uint8, + ) + asset = Image.fromarray(colors[checker], mode="RGB") + draw = ImageDraw.Draw(asset) + draw.rectangle((0, 0, size - 1, size - 1), outline=EXPLAIN_OUTLINE_RGB) + draw.line((2, 2, size - 3, size - 3), fill=EXPLAIN_OUTLINE_RGB, width=1) + draw.line((size - 3, 2, 2, size - 3), fill=EXPLAIN_OUTLINE_RGB, width=1) + return asset + + +def square_region_tile( + size: int, + boundary: tuple[int | None, int | None, int | None, int | None], + palette: Mapping[int, tuple[int, int, int]], +) -> Image.Image: + """Draw one unassigned active cell and its exposed boundary constraints.""" + asset = Image.new("RGB", (size, size), EXPLAIN_ACTIVE_RGB) + draw = ImageDraw.Draw(asset) + draw.rectangle((0, 0, size - 1, size - 1), outline=(174, 181, 193)) + band = max(3, size // 8) + sides = ( + (0, 0, size - 1, band - 1), + (size - band, 0, size - 1, size - 1), + (0, size - band, size - 1, size - 1), + (0, 0, band - 1, size - 1), + ) + for direction, color in enumerate(boundary): + if color is None: + continue + draw.rectangle(sides[direction], fill=palette[color]) + if direction == 0: + line = (0, 0, size - 1, 0) + elif direction == 1: + line = (size - 1, 0, size - 1, size - 1) + elif direction == 2: + line = (0, size - 1, size - 1, size - 1) + else: + line = (0, 0, 0, size - 1) + draw.line(line, fill=EXPLAIN_OUTLINE_RGB, width=1) + return asset + + +def hex_explain_tile( + edges: tuple[int, int, int, int, int, int], + palette: Mapping[int, tuple[int, int, int]], + radius: int, + vertices: tuple[tuple[int, int], ...], + *, + tile_id: int | None, + edge_labels: bool, +) -> Image.Image: + """Draw a pointy-top hex with explicit six-side logical-color bands.""" + size = 2 * radius + 1 + asset = Image.new("RGB", (size, size), EXPLAIN_FACE_RGB) + draw = ImageDraw.Draw(asset) + draw.polygon(vertices, fill=EXPLAIN_FACE_RGB) + width = max(3, radius // 5) + for direction, color in enumerate(edges): + first = vertices[(direction + 1) % 6] + second = vertices[(direction + 2) % 6] + draw.line((first, second), fill=EXPLAIN_OUTLINE_RGB, width=width + 2) + draw.line((first, second), fill=palette[color], width=width) + if tile_id is not None: + centered_text( + draw, + (radius // 2, radius // 2, radius + radius // 2, radius + radius // 2), + str(tile_id), + font=explain_font(max(9, radius // 2)), + ) + if edge_labels and radius >= 28: + font = explain_font(max(8, radius // 4)) + center = (radius, radius) + for direction, color in enumerate(edges): + first = vertices[(direction + 1) % 6] + second = vertices[(direction + 2) % 6] + midpoint = ((first[0] + second[0]) // 2, (first[1] + second[1]) // 2) + label_center = ( + (2 * midpoint[0] + center[0]) // 3, + (2 * midpoint[1] + center[1]) // 3, + ) + label = str(color) + text_box = draw.textbbox((0, 0), label, font=font) + draw.text( + ( + label_center[0] - (text_box[2] - text_box[0]) // 2, + label_center[1] - (text_box[3] - text_box[1]) // 2, + ), + label, + font=font, + fill=EXPLAIN_TEXT_RGB, + ) + return asset + + +def draw_boundary_side( + draw: ImageDraw.ImageDraw, + first: tuple[int, int], + second: tuple[int, int], + color: tuple[int, int, int], + *, + width: int, +) -> None: + """Highlight one exposed constraint with a black-backed colored stroke.""" + draw.line((first, second), fill=EXPLAIN_OUTLINE_RGB, width=width + 4) + draw.line((first, second), fill=color, width=width) + + +def draw_palette_legend( + draw: ImageDraw.ImageDraw, + palette: Mapping[int, tuple[int, int, int]], + origin: tuple[int, int], + *, + title: str = "Logical edge colors", + columns: int = 2, + font_size: int = 14, +) -> tuple[int, int]: + """Draw a numeric palette legend and return its occupied width/height.""" + font = explain_font(font_size) + title_font = explain_font(font_size + 2) + swatch = font_size + 2 + row_height = swatch + 6 + column_width = 82 + colors = sorted(palette) + rows = (len(colors) + columns - 1) // columns + x0, y0 = origin + draw.text((x0, y0), title, font=title_font, fill=EXPLAIN_TEXT_RGB) + title_box = draw.textbbox((x0, y0), title, font=title_font) + content_y = title_box[3] + 8 + for index, logical_color in enumerate(colors): + column = index // rows + row = index % rows + x = x0 + column * column_width + y = content_y + row * row_height + rgb = palette[logical_color] + draw.rectangle( + (x, y, x + swatch, y + swatch), + fill=rgb, + outline=EXPLAIN_OUTLINE_RGB, + ) + draw.text( + (x + swatch + 6, y), + str(logical_color), + font=font, + fill=EXPLAIN_TEXT_RGB, + ) + height = content_y - y0 + rows * row_height + return columns * column_width, height + + +def wrap_text( + draw: ImageDraw.ImageDraw, + text: str, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont, + max_width: int, +) -> list[str]: + """Wrap words using measured Pillow widths without locale dependence.""" + words = text.split() + if not words: + return [""] + lines: list[str] = [] + current = words[0] + for word in words[1:]: + candidate = f"{current} {word}" + box = draw.textbbox((0, 0), candidate, font=font) + if box[2] - box[0] <= max_width: + current = candidate + else: + lines.append(current) + current = word + lines.append(current) + return lines + + +def draw_lines( + draw: ImageDraw.ImageDraw, + lines: Sequence[str], + origin: tuple[int, int], + *, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont, + fill: tuple[int, int, int] = EXPLAIN_TEXT_RGB, + spacing: int = 5, +) -> int: + """Draw lines and return the first unused y coordinate.""" + x, y = origin + sample = draw.textbbox((0, 0), "Ag", font=font) + line_height = sample[3] - sample[1] + spacing + for line in lines: + draw.text((x, y), line, font=font, fill=fill) + y += line_height + return y diff --git a/renderer/wang_snapshot.py b/renderer/wang_snapshot.py new file mode 100644 index 0000000..d88cab1 --- /dev/null +++ b/renderer/wang_snapshot.py @@ -0,0 +1,984 @@ +"""Strict consumer and raster views for static explainability snapshots.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from pathlib import Path +import re +from typing import Final + +import numpy as np +from PIL import Image, ImageDraw + +from wang_explain import ( + EXPLAIN_ACTIVE_RGB, + EXPLAIN_MUTED_RGB, + EXPLAIN_OUTLINE_RGB, + EXPLAIN_PANEL_RGB, + EXPLAIN_TEXT_RGB, + draw_boundary_side, + draw_explain_heading, + draw_inactive_key, + draw_lines, + draw_palette_legend, + explain_font, + hex_explain_tile, + square_explain_tile, + square_inactive_tile, + square_region_tile, + wrap_text, +) +from wang_hex_port import ( + WangHexPort, + WangPresentation, + WangSquareRenderError, + check_square_to_hex, + reduce_square_to_hex, +) +from wang_square import ( + DEFAULT_MARGIN, + DEFAULT_PIXELS_PER_CELL, + MAX_MARGIN, + MAX_PIXELS_PER_CELL, + MIN_PIXELS_PER_CELL, + _build_palette_from_edges, + _check_canvas_limits, + _hex_mask, + _hex_vertices, + _render_integer, + _save_png_atomic, +) + + +FORMULA_SCHEMA: Final = "cm13-formula-snapshot-v1" +TILESET_SCHEMA: Final = "wang-tileset-snapshot-v1" +REGION_SCHEMA: Final = "wang-region-snapshot-v1" +MANIFEST_SCHEMA: Final = "wang-explain-manifest-v1" +DIRECTIONS: Final = ("N", "E", "S", "W") +HEX_DIRECTIONS: Final = ("E", "SE", "SW", "W", "NW", "NE") +_SHA256_PATTERN: Final = re.compile(r"[0-9a-f]{64}") +_FORMULA_PANEL_WIDTH: Final = 360 +_LEGEND_WIDTH: Final = 190 +_PANEL_GAP: Final = 20 +_HEADER_HEIGHT: Final = 58 + + +@dataclass(frozen=True, slots=True) +class FormulaSnapshot: + source_name: str + source_sha256: str + variable_count: int + clauses: tuple[tuple[int, int, int], ...] + + +@dataclass(frozen=True, slots=True) +class TilesetSnapshot: + colors: tuple[int, ...] + tile_edges: tuple[tuple[int, int, int, int], ...] + + +@dataclass(frozen=True, slots=True) +class RegionSnapshot: + min_x: int + min_y: int + max_x: int + max_y: int + source_formula_sha256: str + active: tuple[bool, ...] + boundary: tuple[ + tuple[int | None, int | None, int | None, int | None] | None, + ..., + ] + + @property + def width(self) -> int: + return self.max_x - self.min_x + 1 + + @property + def height(self) -> int: + return self.max_y - self.min_y + 1 + + +@dataclass(frozen=True, slots=True) +class ExplainabilityBundle: + source_formula_sha256: str + formula: FormulaSnapshot + tileset: TilesetSnapshot + region: RegionSnapshot + + +def _fail(path: str, message: str) -> None: + raise WangSquareRenderError(f"explainability snapshot {path}: {message}") + + +def _object(value: object, path: str) -> dict[str, object]: + if type(value) is not dict: + _fail(path, "must be an object") + return value + + +def _array(value: object, path: str) -> list[object]: + if type(value) is not list: + _fail(path, "must be an array") + return value + + +def _string(value: object, path: str) -> str: + if type(value) is not str: + _fail(path, "must be a string") + return value + + +def _integer(value: object, path: str, *, nonnegative: bool) -> int: + if type(value) is not int: + _fail(path, "must be an integer") + if nonnegative and value < 0: + _fail(path, "must be nonnegative") + return value + + +def _fields( + value: dict[str, object], + expected: frozenset[str], + path: str, +) -> None: + actual = frozenset(value) + missing = sorted(expected - actual) + extra = sorted(actual - expected) + if missing: + _fail(path, f"missing fields: {', '.join(missing)}") + if extra: + _fail(path, f"unknown fields: {', '.join(extra)}") + + +def _literal(value: object, expected: str, path: str) -> None: + if _string(value, path) != expected: + _fail(path, f"must equal {expected!r}") + + +def _sha256(value: object, path: str) -> str: + digest = _string(value, path) + if _SHA256_PATTERN.fullmatch(digest) is None: + _fail(path, "must be a lowercase SHA-256 digest") + return digest + + +def _basename(value: object, path: str) -> str: + name = _string(value, path) + if ( + not name + or name in {".", ".."} + or Path(name).name != name + or "/" in name + or "\\" in name + ): + _fail(path, "must be a nonempty artifact basename") + return name + + +def _reject_duplicate_members( + pairs: list[tuple[str, object]], +) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise WangSquareRenderError( + f"JSON object contains duplicate member {key!r}" + ) + result[key] = value + return result + + +def _reject_nonfinite_constant(value: str) -> None: + raise WangSquareRenderError( + f"JSON document contains non-finite number {value}" + ) + + +def _load_json_bytes(encoded: bytes, label: str) -> dict[str, object]: + try: + source = encoded.decode("utf-8") + except UnicodeDecodeError as error: + raise WangSquareRenderError(f"{label}: is not valid UTF-8") from error + try: + document = json.loads( + source, + object_pairs_hook=_reject_duplicate_members, + parse_constant=_reject_nonfinite_constant, + ) + except json.JSONDecodeError as error: + raise WangSquareRenderError( + f"{label}: invalid JSON at line {error.lineno}, " + f"column {error.colno}: {error.msg}" + ) from error + except WangSquareRenderError: + raise + except (ValueError, RecursionError) as error: + raise WangSquareRenderError(f"{label}: invalid JSON value: {error}") from error + return _object(document, label) + + +def _read_bytes(path: Path, label: str) -> bytes: + try: + return path.read_bytes() + except OSError as error: + raise WangSquareRenderError(f"cannot read {label} {path!s}: {error}") from error + + +def _parse_formula(document: dict[str, object]) -> FormulaSnapshot: + _fields( + document, + frozenset({"schema", "source", "variable_count", "clauses"}), + "formula $", + ) + _literal(document["schema"], FORMULA_SCHEMA, "formula $.schema") + source = _object(document["source"], "formula $.source") + _fields(source, frozenset({"name", "sha256"}), "formula $.source") + source_name = _basename(source["name"], "formula $.source.name") + source_digest = _sha256(source["sha256"], "formula $.source.sha256") + variable_count = _integer( + document["variable_count"], + "formula $.variable_count", + nonnegative=True, + ) + if variable_count == 0: + _fail("formula $.variable_count", "must be positive") + clauses = _array(document["clauses"], "formula $.clauses") + if len(clauses) != variable_count: + _fail("formula $.clauses", "length must equal variable_count") + occurrences = [0] * variable_count + projected: list[tuple[int, int, int]] = [] + for clause_id, raw_clause in enumerate(clauses): + path = f"formula $.clauses[{clause_id}]" + clause = _object(raw_clause, path) + _fields(clause, frozenset({"clause_id", "variables"}), path) + actual_id = _integer( + clause["clause_id"], + f"{path}.clause_id", + nonnegative=True, + ) + if actual_id != clause_id: + _fail(f"{path}.clause_id", "must equal canonical position") + variables = _array(clause["variables"], f"{path}.variables") + if len(variables) != 3: + _fail(f"{path}.variables", "must contain exactly three positions") + checked: list[int] = [] + for position, raw_variable in enumerate(variables): + variable = _integer( + raw_variable, + f"{path}.variables[{position}]", + nonnegative=True, + ) + if variable >= variable_count: + _fail(f"{path}.variables[{position}]", "is outside the domain") + occurrences[variable] += 1 + checked.append(variable) + projected.append(tuple(checked)) + if any(count != 3 for count in occurrences): + _fail("formula $.clauses", "every variable must occur exactly three times") + return FormulaSnapshot( + source_name=source_name, + source_sha256=source_digest, + variable_count=variable_count, + clauses=tuple(projected), + ) + + +def _edges( + value: object, + path: str, + *, + optional: bool, +) -> tuple[int | None, int | None, int | None, int | None]: + edges = _object(value, path) + _fields(edges, frozenset(DIRECTIONS), path) + projected: list[int | None] = [] + for direction in DIRECTIONS: + color = edges[direction] + if optional and color is None: + projected.append(None) + else: + projected.append( + _integer(color, f"{path}.{direction}", nonnegative=True) + ) + return tuple(projected) + + +def _parse_tileset(document: dict[str, object]) -> TilesetSnapshot: + _fields( + document, + frozenset({"schema", "geometry", "directions", "colors", "tiles"}), + "tileset $", + ) + _literal(document["schema"], TILESET_SCHEMA, "tileset $.schema") + _literal(document["geometry"], "square", "tileset $.geometry") + if _array(document["directions"], "tileset $.directions") != list(DIRECTIONS): + _fail("tileset $.directions", "must equal N/E/S/W") + raw_colors = _array(document["colors"], "tileset $.colors") + colors = tuple( + _integer(color, f"tileset $.colors[{index}]", nonnegative=True) + for index, color in enumerate(raw_colors) + ) + if not colors or list(colors) != sorted(set(colors)): + _fail("tileset $.colors", "must be nonempty, increasing, and unique") + raw_tiles = _array(document["tiles"], "tileset $.tiles") + if not raw_tiles: + _fail("tileset $.tiles", "must not be empty") + tiles: list[tuple[int, int, int, int]] = [] + for expected_id, raw_tile in enumerate(raw_tiles): + path = f"tileset $.tiles[{expected_id}]" + tile = _object(raw_tile, path) + _fields(tile, frozenset({"tile_id", "edges"}), path) + tile_id = _integer(tile["tile_id"], f"{path}.tile_id", nonnegative=True) + if tile_id != expected_id: + _fail(f"{path}.tile_id", "must equal canonical table position") + projected = _edges(tile["edges"], f"{path}.edges", optional=False) + assert all(color is not None for color in projected) + tiles.append(tuple(projected)) + used = sorted({color for tile in tiles for color in tile}) + if list(colors) != used: + _fail("tileset $.colors", "must equal exactly the colors used by tiles") + return TilesetSnapshot(colors=colors, tile_edges=tuple(tiles)) + + +def _parse_region(document: dict[str, object]) -> RegionSnapshot: + _fields( + document, + frozenset( + { + "schema", + "geometry", + "source_formula_sha256", + "bounds", + "active", + "boundary", + } + ), + "region $", + ) + _literal(document["schema"], REGION_SCHEMA, "region $.schema") + _literal(document["geometry"], "square", "region $.geometry") + source_digest = _sha256( + document["source_formula_sha256"], + "region $.source_formula_sha256", + ) + bounds = _object(document["bounds"], "region $.bounds") + fields = frozenset( + { + "min_x_inclusive", + "min_y_inclusive", + "max_x_inclusive", + "max_y_inclusive", + } + ) + _fields(bounds, fields, "region $.bounds") + min_x = _integer( + bounds["min_x_inclusive"], + "region $.bounds.min_x_inclusive", + nonnegative=False, + ) + min_y = _integer( + bounds["min_y_inclusive"], + "region $.bounds.min_y_inclusive", + nonnegative=False, + ) + max_x = _integer( + bounds["max_x_inclusive"], + "region $.bounds.max_x_inclusive", + nonnegative=False, + ) + max_y = _integer( + bounds["max_y_inclusive"], + "region $.bounds.max_y_inclusive", + nonnegative=False, + ) + if max_x < min_x or max_y < min_y: + _fail("region $.bounds", "inclusive maxima must not precede minima") + width = max_x - min_x + 1 + height = max_y - min_y + 1 + area = width * height + raw_active = _array(document["active"], "region $.active") + raw_boundary = _array(document["boundary"], "region $.boundary") + if len(raw_active) != area or len(raw_boundary) != area: + _fail("region $", f"active and boundary lengths must equal area {area}") + if any(type(value) is not bool for value in raw_active): + _fail("region $.active", "must contain only booleans") + if not any(raw_active): + _fail("region $.active", "must contain an active cell") + active = tuple(raw_active) + boundary: list[ + tuple[int | None, int | None, int | None, int | None] | None + ] = [] + for index, raw_sides in enumerate(raw_boundary): + if not active[index]: + if raw_sides is not None: + _fail(f"region $.boundary[{index}]", "must be null when inactive") + boundary.append(None) + continue + if raw_sides is None: + _fail(f"region $.boundary[{index}]", "must be present when active") + boundary.append( + _edges(raw_sides, f"region $.boundary[{index}]", optional=True) + ) + for index, is_active in enumerate(active): + if not is_active: + continue + sides = boundary[index] + assert sides is not None + x = index % width + y = index // width + for direction, (dx, dy) in enumerate(((0, -1), (1, 0), (0, 1), (-1, 0))): + neighbor_x = x + dx + neighbor_y = y + dy + if ( + 0 <= neighbor_x < width + and 0 <= neighbor_y < height + and active[neighbor_y * width + neighbor_x] + and sides[direction] is not None + ): + _fail( + f"region $.boundary[{index}].{DIRECTIONS[direction]}", + "must be null on an internal edge", + ) + return RegionSnapshot( + min_x=min_x, + min_y=min_y, + max_x=max_x, + max_y=max_y, + source_formula_sha256=source_digest, + active=active, + boundary=tuple(boundary), + ) + + +def load_explainability_bundle(path: str | Path) -> ExplainabilityBundle: + """Load one manifest, verify hashes, and project its three artifacts.""" + manifest_path = Path(path) + manifest = _load_json_bytes( + _read_bytes(manifest_path, "manifest"), + str(manifest_path), + ) + _fields( + manifest, + frozenset({"schema", "stage", "source_formula_sha256", "artifacts"}), + "$", + ) + _literal(manifest["schema"], MANIFEST_SCHEMA, "$.schema") + _literal(manifest["stage"], "region", "$.stage") + source_digest = _sha256( + manifest["source_formula_sha256"], + "$.source_formula_sha256", + ) + artifacts = _object(manifest["artifacts"], "$.artifacts") + _fields(artifacts, frozenset({"formula", "tileset", "region"}), "$.artifacts") + expected_schemas = { + "formula": FORMULA_SCHEMA, + "tileset": TILESET_SCHEMA, + "region": REGION_SCHEMA, + } + documents: dict[str, dict[str, object]] = {} + for name, expected_schema in expected_schemas.items(): + reference_path = f"$.artifacts.{name}" + reference = _object(artifacts[name], reference_path) + _fields(reference, frozenset({"path", "sha256", "schema"}), reference_path) + artifact_name = _basename(reference["path"], f"{reference_path}.path") + expected_digest = _sha256( + reference["sha256"], + f"{reference_path}.sha256", + ) + _literal(reference["schema"], expected_schema, f"{reference_path}.schema") + artifact_path = manifest_path.parent / artifact_name + encoded = _read_bytes(artifact_path, f"{name} artifact") + if hashlib.sha256(encoded).hexdigest() != expected_digest: + _fail(f"{reference_path}.sha256", f"does not match {artifact_name}") + documents[name] = _load_json_bytes(encoded, str(artifact_path)) + + formula = _parse_formula(documents["formula"]) + tileset = _parse_tileset(documents["tileset"]) + region = _parse_region(documents["region"]) + if formula.source_sha256 != source_digest: + _fail("$.source_formula_sha256", "does not match formula snapshot") + if region.source_formula_sha256 != source_digest: + _fail("$.source_formula_sha256", "does not match region snapshot") + colors = set(tileset.colors) + for index, sides in enumerate(region.boundary): + if sides is None: + continue + for direction, color in enumerate(sides): + if color is not None and color not in colors: + _fail( + f"region $.boundary[{index}].{DIRECTIONS[direction]}", + "is absent from the referenced tileset", + ) + return ExplainabilityBundle( + source_formula_sha256=source_digest, + formula=formula, + tileset=tileset, + region=region, + ) + + +def _palette(bundle: ExplainabilityBundle) -> dict[int, tuple[int, int, int]]: + boundary_colors = tuple( + sorted( + { + color + for sides in bundle.region.boundary + if sides is not None + for color in sides + if color is not None + } + ) + ) + edges: tuple[tuple[int, ...], ...] + if boundary_colors: + edges = (*bundle.tileset.tile_edges, boundary_colors) + else: + edges = bundle.tileset.tile_edges + return _build_palette_from_edges(edges) + + +def _formula_lines(formula: FormulaSnapshot) -> list[str]: + lines = [ + f"Source: {formula.source_name}", + "SHA-256:", + formula.source_sha256[:32], + formula.source_sha256[32:], + "", + "Cubic monotone 1-in-3 SAT", + f"Variables: {formula.variable_count}", + f"Clauses: {len(formula.clauses)}", + "", + ] + for clause_id, clause in enumerate(formula.clauses): + variables = ", ".join(f"x{variable}" for variable in clause) + lines.append(f"c{clause_id}: exactly-one({variables})") + return lines + + +def _compose_formula(bundle: ExplainabilityBundle, margin: int) -> np.ndarray: + checked_margin = _render_integer( + margin, + "margin", + minimum=0, + maximum=MAX_MARGIN, + ) + content_width = 780 + width = 2 * checked_margin + content_width + font = explain_font(16) + line_height = 25 + lines = _formula_lines(bundle.formula) + height = 2 * checked_margin + _HEADER_HEIGHT + 20 + len(lines) * line_height + _check_canvas_limits(width, height) + canvas = Image.new("RGB", (width, height), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(canvas) + draw_explain_heading( + draw, + (checked_margin, checked_margin), + title="Parsed formula snapshot", + subtitle=( + "positions and duplicates are preserved exactly; " + "variable IDs are zero-based" + ), + ) + y = checked_margin + _HEADER_HEIGHT + for line in lines: + wrapped = wrap_text(draw, line, font, content_width) + y = draw_lines(draw, wrapped, (checked_margin, y), font=font, spacing=7) + return np.asarray(canvas, dtype=np.uint8) + + +def _hex_tile_edges( + tileset: TilesetSnapshot, +) -> tuple[tuple[tuple[int, int, int, int, int, int], ...], int]: + square = WangPresentation( + min_x=0, + min_y=0, + max_x=0, + max_y=0, + tile_edges=tileset.tile_edges, + cells=(0,), + boundary=((None, None, None, None),), + ) + port = reduce_square_to_hex(square) + check_square_to_hex(square, port) + return port.tile_edges, port.fresh_color + + +def _region_hex_port(bundle: ExplainabilityBundle) -> WangHexPort: + region = bundle.region + square = WangPresentation( + min_x=region.min_x, + min_y=region.min_y, + max_x=region.max_x, + max_y=region.max_y, + tile_edges=bundle.tileset.tile_edges, + # The static region has no assignment. Empty presentation cells let + # the existing port/checker validate only table, bounds, and boundary. + cells=(None,) * len(region.active), + boundary=region.boundary, + ) + port = reduce_square_to_hex(square) + check_square_to_hex(square, port) + return port + + +def _compose_tileset( + bundle: ExplainabilityBundle, + pixels_per_cell: int, + margin: int, + *, + hex_mode: bool, +) -> np.ndarray: + checked = _render_integer( + pixels_per_cell, + "pixels_per_cell", + minimum=MIN_PIXELS_PER_CELL, + maximum=MAX_PIXELS_PER_CELL, + ) + checked_margin = _render_integer( + margin, + "margin", + minimum=0, + maximum=MAX_MARGIN, + ) + columns = 6 + rows = (len(bundle.tileset.tile_edges) + columns - 1) // columns + gap = 14 + palette = _palette(bundle) + fresh: int | None = None + if hex_mode: + tile_edges, fresh = _hex_tile_edges(bundle.tileset) + palette = _build_palette_from_edges(tile_edges) + radius = max(28, checked) + shoulder = radius // 2 + tile_width = 2 * radius + 1 + tile_height = 2 * radius + 1 + else: + tile_edges = bundle.tileset.tile_edges + tile_width = tile_height = max(64, checked * 2) + radius = 0 + shoulder = 0 + grid_width = columns * tile_width + (columns - 1) * gap + grid_height = rows * tile_height + (rows - 1) * gap + width = 2 * checked_margin + grid_width + _PANEL_GAP + _LEGEND_WIDTH + height = max(2 * checked_margin + _HEADER_HEIGHT + grid_height, 410) + _check_canvas_limits(width, height) + canvas = Image.new("RGB", (width, height), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(canvas) + geometry = "hex" if hex_mode else "square" + draw_explain_heading( + draw, + (checked_margin, checked_margin), + title=f"Tile sheet ({len(tile_edges)} tiles) - {geometry}", + subtitle="tile ID at center; numeric logical color on every side", + ) + grid_y = checked_margin + _HEADER_HEIGHT + vertices = _hex_vertices(radius, shoulder) if hex_mode else () + for tile_id, edges in enumerate(tile_edges): + column = tile_id % columns + row = tile_id // columns + x = checked_margin + column * (tile_width + gap) + y = grid_y + row * (tile_height + gap) + if hex_mode: + asset = hex_explain_tile( + edges, + palette, + radius, + vertices, + tile_id=tile_id, + edge_labels=True, + ) + canvas.paste(asset, (x, y), _hex_mask(radius, vertices)) + else: + asset = square_explain_tile( + edges, + palette, + tile_width, + tile_id=tile_id, + edge_labels=True, + ) + canvas.paste(asset, (x, y)) + legend_x = checked_margin + grid_width + _PANEL_GAP + _, legend_height = draw_palette_legend( + ImageDraw.Draw(canvas), + palette, + (legend_x, grid_y), + columns=2, + ) + if fresh is not None: + ImageDraw.Draw(canvas).text( + (legend_x, grid_y + legend_height + 10), + f"kappa = {fresh} (fresh axis)", + font=explain_font(13), + fill=EXPLAIN_TEXT_RGB, + ) + return np.asarray(canvas, dtype=np.uint8) + + +def _formula_panel( + canvas: Image.Image, + bundle: ExplainabilityBundle, + origin: tuple[int, int], + max_height: int, +) -> int: + draw = ImageDraw.Draw(canvas) + x, y = origin + draw.text( + (x, y), + "Formula being simulated", + font=explain_font(17), + fill=EXPLAIN_TEXT_RGB, + ) + y += 30 + font = explain_font(13) + for line in _formula_lines(bundle.formula): + wrapped = wrap_text(draw, line, font, _FORMULA_PANEL_WIDTH) + for item in wrapped: + if y + 20 > max_height: + draw.text((x, y), "...", font=font, fill=EXPLAIN_MUTED_RGB) + return y + 22 + draw.text((x, y), item, font=font, fill=EXPLAIN_TEXT_RGB) + y += 20 + return y + + +def _compose_region_square( + bundle: ExplainabilityBundle, + pixels_per_cell: int, + margin: int, +) -> np.ndarray: + ppc = _render_integer( + pixels_per_cell, + "pixels_per_cell", + minimum=MIN_PIXELS_PER_CELL, + maximum=MAX_PIXELS_PER_CELL, + ) + checked_margin = _render_integer( + margin, + "margin", + minimum=0, + maximum=MAX_MARGIN, + ) + region = bundle.region + grid_width = region.width * ppc + grid_height = region.height * ppc + side_width = max(_FORMULA_PANEL_WIDTH, _LEGEND_WIDTH) + width = 2 * checked_margin + grid_width + _PANEL_GAP + side_width + formula_height = 30 + len(_formula_lines(bundle.formula)) * 20 + legend_rows = (len(_palette(bundle)) + 1) // 2 + side_height = formula_height + 20 + 36 + legend_rows * 22 + 42 + height = max( + 2 * checked_margin + _HEADER_HEIGHT + grid_height, + 2 * checked_margin + _HEADER_HEIGHT + side_height, + ) + _check_canvas_limits(width, height) + canvas = Image.new("RGB", (width, height), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(canvas) + draw_explain_heading( + draw, + (checked_margin, checked_margin), + title="Yang-Zhang region - before solving", + subtitle=( + "active mask and exposed boundary constraints; " + "no tile is assigned" + ), + ) + grid_x = checked_margin + grid_y = checked_margin + _HEADER_HEIGHT + palette = _palette(bundle) + inactive = square_inactive_tile(ppc) + for index, active in enumerate(region.active): + x = grid_x + (index % region.width) * ppc + y = grid_y + (index // region.width) * ppc + if not active: + canvas.paste(inactive, (x, y)) + continue + sides = region.boundary[index] + assert sides is not None + canvas.paste(square_region_tile(ppc, sides, palette), (x, y)) + side_x = grid_x + grid_width + _PANEL_GAP + formula_end = _formula_panel( + canvas, + bundle, + (side_x, grid_y), + height - checked_margin, + ) + legend_y = formula_end + 16 + _, legend_height = draw_palette_legend( + ImageDraw.Draw(canvas), + palette, + (side_x, legend_y), + columns=2, + ) + draw_inactive_key( + ImageDraw.Draw(canvas), + (side_x, legend_y + legend_height + 8), + ) + return np.asarray(canvas, dtype=np.uint8) + + +def _compose_region_hex( + bundle: ExplainabilityBundle, + pixels_per_cell: int, + margin: int, +) -> np.ndarray: + radius = _render_integer( + pixels_per_cell, + "pixels_per_cell", + minimum=MIN_PIXELS_PER_CELL, + maximum=MAX_PIXELS_PER_CELL, + ) + checked_margin = _render_integer( + margin, + "margin", + minimum=0, + maximum=MAX_MARGIN, + ) + region = bundle.region + port = _region_hex_port(bundle) + shoulder = radius // 2 + raw_width = ( + 2 * radius * (region.width - 1) + + radius * (region.height - 1) + + 2 * radius + + 1 + ) + raw_height = ( + (radius + shoulder) * (region.height - 1) + 2 * radius + 1 + ) + side_width = max(_FORMULA_PANEL_WIDTH, _LEGEND_WIDTH) + width = 2 * checked_margin + raw_width + _PANEL_GAP + side_width + formula_height = 30 + len(_formula_lines(bundle.formula)) * 20 + legend_rows = (len(_palette(bundle)) + 1) // 2 + side_height = formula_height + 20 + 36 + legend_rows * 22 + 42 + height = max( + 2 * checked_margin + _HEADER_HEIGHT + raw_height, + 2 * checked_margin + _HEADER_HEIGHT + side_height, + ) + _check_canvas_limits(width, height) + canvas = Image.new("RGB", (width, height), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(canvas) + draw_explain_heading( + draw, + (checked_margin, checked_margin), + title="Yang-Zhang region - checked axial presentation", + subtitle="square coordinates (x,y) become (q,r); no tile is assigned", + ) + grid_x = checked_margin + grid_y = checked_margin + _HEADER_HEIGHT + vertices = _hex_vertices(radius, shoulder) + mask = _hex_mask(radius, vertices) + neutral = Image.new("RGB", (2 * radius + 1, 2 * radius + 1), EXPLAIN_PANEL_RGB) + ImageDraw.Draw(neutral).polygon( + vertices, + fill=EXPLAIN_ACTIVE_RGB, + outline=EXPLAIN_OUTLINE_RGB, + ) + inactive = Image.new("RGB", neutral.size, EXPLAIN_PANEL_RGB) + inactive_draw = ImageDraw.Draw(inactive) + inactive_draw.polygon( + vertices, + fill=(215, 220, 228), + outline=EXPLAIN_OUTLINE_RGB, + ) + inactive_draw.line( + (radius // 2, radius // 2, radius + radius // 2, radius + radius // 2), + fill=EXPLAIN_OUTLINE_RGB, + width=1, + ) + inactive_draw.line( + (radius + radius // 2, radius // 2, radius // 2, radius + radius // 2), + fill=EXPLAIN_OUTLINE_RGB, + width=1, + ) + palette = _palette(bundle) + min_anchor_x = 2 * radius * region.min_x + radius * region.min_y + min_anchor_y = (radius + shoulder) * region.min_y + overlays: list[ + tuple[tuple[int, int], tuple[int, int], tuple[int, int, int]] + ] = [] + for index, active in enumerate(region.active): + local_q = index % region.width + local_r = index // region.width + q = region.min_x + local_q + r = region.min_y + local_r + anchor_x = 2 * radius * q + radius * r + anchor_y = (radius + shoulder) * r + x = grid_x + anchor_x - min_anchor_x + y = grid_y + anchor_y - min_anchor_y + canvas.paste(neutral if active else inactive, (x, y), mask) + if not active: + continue + sides = port.boundary[index] + assert sides is not None + for direction, color in enumerate(sides): + if color is None: + continue + first = vertices[(direction + 1) % 6] + second = vertices[(direction + 2) % 6] + overlays.append( + ( + (x + first[0], y + first[1]), + (x + second[0], y + second[1]), + palette[color], + ) + ) + draw = ImageDraw.Draw(canvas) + for first, second, rgb in overlays: + draw_boundary_side(draw, first, second, rgb, width=max(2, radius // 7)) + side_x = grid_x + raw_width + _PANEL_GAP + formula_end = _formula_panel( + canvas, + bundle, + (side_x, grid_y), + height - checked_margin, + ) + legend_y = formula_end + 16 + _, legend_height = draw_palette_legend( + ImageDraw.Draw(canvas), + palette, + (side_x, legend_y), + columns=2, + ) + draw_inactive_key( + ImageDraw.Draw(canvas), + (side_x, legend_y + legend_height + 8), + ) + return np.asarray(canvas, dtype=np.uint8) + + +def render_pipeline_snapshot( + input_path: str | Path, + output_path: str | Path, + *, + view: str, + pixels_per_cell: int = DEFAULT_PIXELS_PER_CELL, + margin: int = DEFAULT_MARGIN, + hex_mode: bool = False, +) -> None: + """Render one strict manifest view and atomically install its PNG.""" + bundle = load_explainability_bundle(input_path) + if view == "formula": + if hex_mode: + raise WangSquareRenderError("--hex is not meaningful for formula view") + canvas = _compose_formula(bundle, margin) + elif view == "tileset": + canvas = _compose_tileset( + bundle, + pixels_per_cell, + margin, + hex_mode=hex_mode, + ) + elif view == "region": + if hex_mode: + canvas = _compose_region_hex(bundle, pixels_per_cell, margin) + else: + canvas = _compose_region_square(bundle, pixels_per_cell, margin) + else: + raise WangSquareRenderError( + "snapshot view must be one of formula, tileset, or region" + ) + _save_png_atomic(canvas, output_path) diff --git a/renderer/wang_square.py b/renderer/wang_square.py index 7016620..2567b34 100644 --- a/renderer/wang_square.py +++ b/renderer/wang_square.py @@ -19,6 +19,18 @@ import numpy as np from PIL import Image, ImageDraw +from wang_explain import ( + EXPLAIN_PANEL_RGB, + EXPLAIN_TEXT_RGB, + draw_boundary_side, + draw_explain_heading, + draw_inactive_key, + draw_palette_legend, + explain_font, + hex_explain_tile, + square_explain_tile, + square_inactive_tile, +) from wang_hex_port import ( SQUARE_DIRECTIONS, WangHexPort, @@ -41,6 +53,9 @@ MAX_MARGIN: Final = 4096 MAX_CANVAS_SIDE: Final = 16384 MAX_CANVAS_PIXELS: Final = 64 * 1024 * 1024 +EXPLAIN_HEADER_HEIGHT: Final = 54 +EXPLAIN_LEGEND_WIDTH: Final = 190 +EXPLAIN_PANEL_GAP: Final = 20 BACKGROUND_RGB: Final = (248, 248, 244) GRID_RGB: Final = (32, 35, 42) @@ -588,6 +603,270 @@ def _compose_wang_hex( return np.asarray(canvas, dtype=np.uint8) +def _palette_with_boundary( + tile_edges: tuple[tuple[int, ...], ...], + boundary: tuple[tuple[int | None, ...] | None, ...], +) -> dict[int, tuple[int, int, int]]: + colors = { + color + for sides in boundary + if sides is not None + for color in sides + if color is not None + } + if colors: + return _build_palette_from_edges((*tile_edges, tuple(sorted(colors)))) + return _build_palette_from_edges(tile_edges) + + +def _compose_wang_square_explain( + presentation: WangPresentation, + *, + pixels_per_cell: int = DEFAULT_PIXELS_PER_CELL, + margin: int = DEFAULT_MARGIN, +) -> np.ndarray: + """Compose an opt-in square view with explicit colored side bands.""" + ppc = _render_integer( + pixels_per_cell, + "pixels_per_cell", + minimum=MIN_PIXELS_PER_CELL, + maximum=MAX_PIXELS_PER_CELL, + ) + checked_margin = _render_integer( + margin, + "margin", + minimum=0, + maximum=MAX_MARGIN, + ) + grid_width = presentation.width * ppc + grid_height = presentation.height * ppc + canvas_width = ( + 2 * checked_margin + + grid_width + + EXPLAIN_PANEL_GAP + + EXPLAIN_LEGEND_WIDTH + ) + canvas_height = max( + 2 * checked_margin + EXPLAIN_HEADER_HEIGHT + grid_height, + 2 * checked_margin + EXPLAIN_HEADER_HEIGHT + 300, + ) + _check_canvas_limits(canvas_width, canvas_height) + + canvas = Image.new( + "RGB", + (canvas_width, canvas_height), + EXPLAIN_PANEL_RGB, + ) + draw = ImageDraw.Draw(canvas) + draw_explain_heading( + draw, + (checked_margin, checked_margin), + title="Verified Wang solution - square", + subtitle="colored N/E/S/W bands; tile ID at each active cell", + ) + grid_x = checked_margin + grid_y = checked_margin + EXPLAIN_HEADER_HEIGHT + palette = _palette_with_boundary( + presentation.tile_edges, + presentation.boundary, + ) + inactive = square_inactive_tile(ppc) + tile_assets: dict[int, Image.Image] = {} + overlays: list[ + tuple[ + tuple[int, int], + tuple[int, int], + tuple[int, int, int], + ] + ] = [] + + for index, tile_id in enumerate(presentation.cells): + local_x = index % presentation.width + local_y = index // presentation.width + x = grid_x + local_x * ppc + y = grid_y + local_y * ppc + if tile_id is None: + canvas.paste(inactive, (x, y)) + continue + asset = tile_assets.get(tile_id) + if asset is None: + asset = square_explain_tile( + presentation.tile_edges[tile_id], + palette, + ppc, + tile_id=tile_id, + edge_labels=False, + ) + tile_assets[tile_id] = asset + canvas.paste(asset, (x, y)) + sides = presentation.boundary[index] + if sides is None: + continue + segments = ( + ((x, y), (x + ppc - 1, y)), + ((x + ppc - 1, y), (x + ppc - 1, y + ppc - 1)), + ((x, y + ppc - 1), (x + ppc - 1, y + ppc - 1)), + ((x, y), (x, y + ppc - 1)), + ) + for direction, color in enumerate(sides): + if color is not None: + overlays.append((*segments[direction], palette[color])) + + draw = ImageDraw.Draw(canvas) + for first, second, rgb in overlays: + draw_boundary_side( + draw, + first, + second, + rgb, + width=max(2, ppc // 12), + ) + legend_x = grid_x + grid_width + EXPLAIN_PANEL_GAP + _, legend_height = draw_palette_legend( + draw, + palette, + (legend_x, grid_y), + columns=2, + ) + draw_inactive_key(draw, (legend_x, grid_y + legend_height + 12)) + return np.asarray(canvas, dtype=np.uint8) + + +def _compose_wang_hex_explain( + square: WangPresentation, + *, + pixels_per_cell: int = DEFAULT_PIXELS_PER_CELL, + margin: int = DEFAULT_MARGIN, +) -> np.ndarray: + """Compose an opt-in checked hex view with explicit six-side bands.""" + presentation = reduce_square_to_hex(square) + check_square_to_hex(square, presentation) + raw_width, raw_height, radius, shoulder, _ = _hex_canvas_dimensions( + presentation, + pixels_per_cell, + 0, + ) + checked_margin = _render_integer( + margin, + "margin", + minimum=0, + maximum=MAX_MARGIN, + ) + canvas_width = ( + 2 * checked_margin + + raw_width + + EXPLAIN_PANEL_GAP + + EXPLAIN_LEGEND_WIDTH + ) + canvas_height = max( + 2 * checked_margin + EXPLAIN_HEADER_HEIGHT + raw_height, + 2 * checked_margin + EXPLAIN_HEADER_HEIGHT + 350, + ) + _check_canvas_limits(canvas_width, canvas_height) + canvas = Image.new( + "RGB", + (canvas_width, canvas_height), + EXPLAIN_PANEL_RGB, + ) + draw = ImageDraw.Draw(canvas) + draw_explain_heading( + draw, + (checked_margin, checked_margin), + title="Verified Wang solution - Basire/Culik hex view", + subtitle="E/SE/SW/W/NW/NE bands; kappa marks the presentation-only axis", + ) + grid_x = checked_margin + grid_y = checked_margin + EXPLAIN_HEADER_HEIGHT + vertices = _hex_vertices(radius, shoulder) + mask = _hex_mask(radius, vertices) + inactive = _hex_hole_asset(radius, vertices) + palette = _palette_with_boundary( + presentation.tile_edges, + presentation.boundary, + ) + tile_assets: dict[int, Image.Image] = {} + overlays: list[ + tuple[ + tuple[int, int], + tuple[int, int], + tuple[int, int, int], + ] + ] = [] + min_anchor_x = ( + 2 * radius * presentation.min_q + radius * presentation.min_r + ) + min_anchor_y = (radius + shoulder) * presentation.min_r + for index, tile_id in enumerate(presentation.cells): + local_q = index % presentation.width + local_r = index // presentation.width + q = presentation.min_q + local_q + r = presentation.min_r + local_r + anchor_x = 2 * radius * q + radius * r + anchor_y = (radius + shoulder) * r + x = grid_x + anchor_x - min_anchor_x + y = grid_y + anchor_y - min_anchor_y + if tile_id is None: + asset = inactive + else: + asset = tile_assets.get(tile_id) + if asset is None: + asset = hex_explain_tile( + presentation.tile_edges[tile_id], + palette, + radius, + vertices, + tile_id=tile_id, + edge_labels=False, + ) + tile_assets[tile_id] = asset + canvas.paste(asset, (x, y), mask) + if tile_id is None: + continue + sides = presentation.boundary[index] + if sides is None: + continue + for direction, color in enumerate(sides): + if color is None: + continue + first = vertices[(direction + 1) % 6] + second = vertices[(direction + 2) % 6] + overlays.append( + ( + (x + first[0], y + first[1]), + (x + second[0], y + second[1]), + palette[color], + ) + ) + + draw = ImageDraw.Draw(canvas) + for first, second, rgb in overlays: + draw_boundary_side( + draw, + first, + second, + rgb, + width=max(2, radius // 8), + ) + legend_x = grid_x + raw_width + EXPLAIN_PANEL_GAP + _, legend_height = draw_palette_legend( + draw, + palette, + (legend_x, grid_y), + columns=2, + ) + draw.text( + (legend_x, grid_y + legend_height + 8), + f"kappa = {presentation.fresh_color}", + font=explain_font(13), + fill=EXPLAIN_TEXT_RGB, + ) + draw_inactive_key( + draw, + (legend_x, grid_y + legend_height + 38), + ) + return np.asarray(canvas, dtype=np.uint8) + + def _save_png_atomic(canvas: np.ndarray, output_path: str | Path) -> None: destination = Path(output_path) temporary: Path | None = None @@ -627,15 +906,28 @@ def render_wang_square( pixels_per_cell: int = DEFAULT_PIXELS_PER_CELL, margin: int = DEFAULT_MARGIN, hex_mode: bool = False, + explain: bool = False, ) -> None: """Load a v1 presentation and atomically write square or hex pixels.""" presentation = load_wang_presentation(input_path) - if hex_mode: + if hex_mode and explain: + canvas = _compose_wang_hex_explain( + presentation, + pixels_per_cell=pixels_per_cell, + margin=margin, + ) + elif hex_mode: canvas = _compose_wang_hex( presentation, pixels_per_cell=pixels_per_cell, margin=margin, ) + elif explain: + canvas = _compose_wang_square_explain( + presentation, + pixels_per_cell=pixels_per_cell, + margin=margin, + ) else: canvas = compose_wang_square( presentation, @@ -648,10 +940,13 @@ def render_wang_square( def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( - "Render a wang-solution-v1 tiling as a diagnostic square or hex PNG" + "Render a Wang solution or a static explainability snapshot as PNG" ) ) - parser.add_argument("input", help="path to a wang-solution-v1 JSON file") + parser.add_argument( + "input", + help="path to wang-solution-v1 JSON or a wang-explain-manifest-v1 file", + ) parser.add_argument("output", help="path to the output PNG") parser.add_argument( "--pixels-per-cell", @@ -673,6 +968,17 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="port the square witness to pointy-top axial hexagons", ) + parser.add_argument( + "--explain", + action="store_true", + help="add colored edge bands, tile IDs, boundary emphasis, and a legend", + ) + parser.add_argument( + "--view", + choices=("solution", "formula", "tileset", "region"), + default="solution", + help="input stage to render (default: solution)", + ) return parser @@ -680,13 +986,30 @@ def main(argv: list[str] | None = None) -> None: parser = _parser() args = parser.parse_args(argv) try: - render_wang_square( - args.input, - args.output, - pixels_per_cell=args.pixels_per_cell, - margin=args.margin, - hex_mode=args.hex, - ) + if args.view == "solution": + render_wang_square( + args.input, + args.output, + pixels_per_cell=args.pixels_per_cell, + margin=args.margin, + hex_mode=args.hex, + explain=args.explain, + ) + else: + if args.explain: + raise WangSquareRenderError( + "snapshot views are already explainable; omit --explain" + ) + from wang_snapshot import render_pipeline_snapshot + + render_pipeline_snapshot( + args.input, + args.output, + view=args.view, + pixels_per_cell=args.pixels_per_cell, + margin=args.margin, + hex_mode=args.hex, + ) except (FileNotFoundError, WangSquareRenderError) as error: parser.error(str(error)) diff --git a/schemas/cm13-formula-snapshot-v1.schema.json b/schemas/cm13-formula-snapshot-v1.schema.json new file mode 100644 index 0000000..2d16852 --- /dev/null +++ b/schemas/cm13-formula-snapshot-v1.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tiling-foundry:schema:cm13-formula-snapshot-v1", + "title": "Tiling Foundry parsed CM13 formula snapshot v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "source", "variable_count", "clauses"], + "properties": { + "schema": { + "const": "cm13-formula-snapshot-v1" + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["name", "sha256"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[^/\\\\]+$", + "not": { + "enum": [".", ".."] + } + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "variable_count": { + "type": "integer", + "minimum": 1 + }, + "clauses": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["clause_id", "variables"], + "properties": { + "clause_id": { + "type": "integer", + "minimum": 0 + }, + "variables": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } +} diff --git a/schemas/wang-explain-manifest-v1.schema.json b/schemas/wang-explain-manifest-v1.schema.json new file mode 100644 index 0000000..9fa3554 --- /dev/null +++ b/schemas/wang-explain-manifest-v1.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tiling-foundry:schema:wang-explain-manifest-v1", + "title": "Tiling Foundry static explainability manifest v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "stage", "source_formula_sha256", "artifacts"], + "properties": { + "schema": { + "const": "wang-explain-manifest-v1" + }, + "stage": { + "const": "region" + }, + "source_formula_sha256": { + "$ref": "#/$defs/sha256" + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "required": ["formula", "tileset", "region"], + "properties": { + "formula": { + "$ref": "#/$defs/formula_reference" + }, + "tileset": { + "$ref": "#/$defs/tileset_reference" + }, + "region": { + "$ref": "#/$defs/region_reference" + } + } + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "artifact_path": { + "type": "string", + "minLength": 1, + "pattern": "^[^/\\\\]+$", + "not": { + "enum": [".", ".."] + } + }, + "formula_reference": { + "$ref": "#/$defs/reference", + "properties": { + "schema": {"const": "cm13-formula-snapshot-v1"} + } + }, + "tileset_reference": { + "$ref": "#/$defs/reference", + "properties": { + "schema": {"const": "wang-tileset-snapshot-v1"} + } + }, + "region_reference": { + "$ref": "#/$defs/reference", + "properties": { + "schema": {"const": "wang-region-snapshot-v1"} + } + }, + "reference": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "schema"], + "properties": { + "path": {"$ref": "#/$defs/artifact_path"}, + "sha256": {"$ref": "#/$defs/sha256"}, + "schema": {"type": "string"} + } + } + } +} diff --git a/schemas/wang-region-snapshot-v1.schema.json b/schemas/wang-region-snapshot-v1.schema.json new file mode 100644 index 0000000..16e298f --- /dev/null +++ b/schemas/wang-region-snapshot-v1.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tiling-foundry:schema:wang-region-snapshot-v1", + "title": "Tiling Foundry unassigned square Wang region snapshot v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "geometry", + "source_formula_sha256", + "bounds", + "active", + "boundary" + ], + "properties": { + "schema": { + "const": "wang-region-snapshot-v1" + }, + "geometry": { + "const": "square" + }, + "source_formula_sha256": { + "$ref": "#/$defs/sha256" + }, + "bounds": { + "$ref": "#/$defs/inclusive_bounds" + }, + "active": { + "type": "array", + "minItems": 1, + "contains": {"const": true}, + "items": { + "type": "boolean" + } + }, + "boundary": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + {"$ref": "#/$defs/optional_edges"}, + {"type": "null"} + ] + } + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "inclusive_bounds": { + "type": "object", + "additionalProperties": false, + "required": [ + "min_x_inclusive", + "min_y_inclusive", + "max_x_inclusive", + "max_y_inclusive" + ], + "properties": { + "min_x_inclusive": {"type": "integer"}, + "min_y_inclusive": {"type": "integer"}, + "max_x_inclusive": {"type": "integer"}, + "max_y_inclusive": {"type": "integer"} + } + }, + "nullable_color": { + "oneOf": [ + {"type": "integer", "minimum": 0}, + {"type": "null"} + ] + }, + "optional_edges": { + "type": "object", + "additionalProperties": false, + "required": ["N", "E", "S", "W"], + "properties": { + "N": {"$ref": "#/$defs/nullable_color"}, + "E": {"$ref": "#/$defs/nullable_color"}, + "S": {"$ref": "#/$defs/nullable_color"}, + "W": {"$ref": "#/$defs/nullable_color"} + } + } + } +} diff --git a/schemas/wang-tileset-snapshot-v1.schema.json b/schemas/wang-tileset-snapshot-v1.schema.json new file mode 100644 index 0000000..b34e26d --- /dev/null +++ b/schemas/wang-tileset-snapshot-v1.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tiling-foundry:schema:wang-tileset-snapshot-v1", + "title": "Tiling Foundry square Wang tileset snapshot v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "geometry", "directions", "colors", "tiles"], + "properties": { + "schema": { + "const": "wang-tileset-snapshot-v1" + }, + "geometry": { + "const": "square" + }, + "directions": { + "type": "array", + "prefixItems": [ + {"const": "N"}, + {"const": "E"}, + {"const": "S"}, + {"const": "W"} + ], + "minItems": 4, + "maxItems": 4 + }, + "colors": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/color" + } + }, + "tiles": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["tile_id", "edges"], + "properties": { + "tile_id": { + "type": "integer", + "minimum": 0 + }, + "edges": { + "$ref": "#/$defs/edges" + } + } + } + } + }, + "$defs": { + "color": { + "type": "integer", + "minimum": 0 + }, + "edges": { + "type": "object", + "additionalProperties": false, + "required": ["N", "E", "S", "W"], + "properties": { + "N": {"$ref": "#/$defs/color"}, + "E": {"$ref": "#/$defs/color"}, + "S": {"$ref": "#/$defs/color"}, + "W": {"$ref": "#/$defs/color"} + } + } + } +} diff --git a/tests/fixtures/pipeline_sat_explain/formula-5098eb9a85adebf28757b1368e9307fdb5fea0617de3256b717ead1af1d67815.json b/tests/fixtures/pipeline_sat_explain/formula-5098eb9a85adebf28757b1368e9307fdb5fea0617de3256b717ead1af1d67815.json new file mode 100644 index 0000000..e7775ba --- /dev/null +++ b/tests/fixtures/pipeline_sat_explain/formula-5098eb9a85adebf28757b1368e9307fdb5fea0617de3256b717ead1af1d67815.json @@ -0,0 +1,34 @@ +{ + "schema": "cm13-formula-snapshot-v1", + "source": { + "name": "pipeline_sat.cm13", + "sha256": "3caaa6b29ac988fb4f51cc7071202d83ea1591ba6170e683b6da449cb3641542" + }, + "variable_count": 3, + "clauses": [ + { + "clause_id": 0, + "variables": [ + 0, + 0, + 1 + ] + }, + { + "clause_id": 1, + "variables": [ + 0, + 1, + 2 + ] + }, + { + "clause_id": 2, + "variables": [ + 1, + 2, + 2 + ] + } + ] +} diff --git a/tests/fixtures/pipeline_sat_explain/manifest.json b/tests/fixtures/pipeline_sat_explain/manifest.json new file mode 100644 index 0000000..173d555 --- /dev/null +++ b/tests/fixtures/pipeline_sat_explain/manifest.json @@ -0,0 +1,22 @@ +{ + "schema": "wang-explain-manifest-v1", + "stage": "region", + "source_formula_sha256": "3caaa6b29ac988fb4f51cc7071202d83ea1591ba6170e683b6da449cb3641542", + "artifacts": { + "formula": { + "path": "formula-5098eb9a85adebf28757b1368e9307fdb5fea0617de3256b717ead1af1d67815.json", + "sha256": "5098eb9a85adebf28757b1368e9307fdb5fea0617de3256b717ead1af1d67815", + "schema": "cm13-formula-snapshot-v1" + }, + "tileset": { + "path": "tileset-462a102690c420bfe52a634e7b365a6be3bfcf59169f0acd96264831196723fa.json", + "sha256": "462a102690c420bfe52a634e7b365a6be3bfcf59169f0acd96264831196723fa", + "schema": "wang-tileset-snapshot-v1" + }, + "region": { + "path": "region-aad4dc7dcb46ed070995bf975248a7f2f0bf27d69db3eb653208d08c5826199a.json", + "sha256": "aad4dc7dcb46ed070995bf975248a7f2f0bf27d69db3eb653208d08c5826199a", + "schema": "wang-region-snapshot-v1" + } + } +} diff --git a/tests/fixtures/pipeline_sat_explain/region-aad4dc7dcb46ed070995bf975248a7f2f0bf27d69db3eb653208d08c5826199a.json b/tests/fixtures/pipeline_sat_explain/region-aad4dc7dcb46ed070995bf975248a7f2f0bf27d69db3eb653208d08c5826199a.json new file mode 100644 index 0000000..fa245cd --- /dev/null +++ b/tests/fixtures/pipeline_sat_explain/region-aad4dc7dcb46ed070995bf975248a7f2f0bf27d69db3eb653208d08c5826199a.json @@ -0,0 +1,3137 @@ +{ + "schema": "wang-region-snapshot-v1", + "geometry": "square", + "source_formula_sha256": "3caaa6b29ac988fb4f51cc7071202d83ea1591ba6170e683b6da449cb3641542", + "bounds": { + "min_x_inclusive": 0, + "min_y_inclusive": 0, + "max_x_inclusive": 40, + "max_y_inclusive": 10 + }, + "active": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "boundary": [ + { + "N": 0, + "E": null, + "S": null, + "W": 1 + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 6, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 6, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 6, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 6, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 6, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 6, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": 4, + "S": null, + "W": null + }, + null, + { + "N": null, + "E": null, + "S": null, + "W": 1 + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": 4, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": 1 + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": 3, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": 2 + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": 2, + "S": null, + "W": null + }, + null, + null, + { + "N": null, + "E": null, + "S": null, + "W": 1 + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": 4, + "S": null, + "W": null + }, + null, + { + "N": null, + "E": null, + "S": null, + "W": 1 + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": 4, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": 1 + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": 3, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": 2 + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": 2, + "S": null, + "W": null + }, + null, + null, + { + "N": null, + "E": null, + "S": null, + "W": 1 + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": 4, + "S": null, + "W": null + }, + null, + { + "N": null, + "E": null, + "S": null, + "W": 1 + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": null, + "W": null + }, + { + "N": 0, + "E": 4, + "S": null, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": 1 + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 5, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 5, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 5, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 5, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 5, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 5, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": null, + "S": 0, + "W": null + }, + { + "N": null, + "E": 3, + "S": 0, + "W": null + } + ] +} diff --git a/tests/fixtures/pipeline_sat_explain/tileset-462a102690c420bfe52a634e7b365a6be3bfcf59169f0acd96264831196723fa.json b/tests/fixtures/pipeline_sat_explain/tileset-462a102690c420bfe52a634e7b365a6be3bfcf59169f0acd96264831196723fa.json new file mode 100644 index 0000000..41d70c7 --- /dev/null +++ b/tests/fixtures/pipeline_sat_explain/tileset-462a102690c420bfe52a634e7b365a6be3bfcf59169f0acd96264831196723fa.json @@ -0,0 +1,237 @@ +{ + "schema": "wang-tileset-snapshot-v1", + "geometry": "square", + "directions": [ + "N", + "E", + "S", + "W" + ], + "colors": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "tiles": [ + { + "tile_id": 0, + "edges": { + "N": 0, + "E": 2, + "S": 7, + "W": 1 + } + }, + { + "tile_id": 1, + "edges": { + "N": 7, + "E": 2, + "S": 8, + "W": 1 + } + }, + { + "tile_id": 2, + "edges": { + "N": 8, + "E": 2, + "S": 0, + "W": 1 + } + }, + { + "tile_id": 3, + "edges": { + "N": 0, + "E": 3, + "S": 0, + "W": 1 + } + }, + { + "tile_id": 4, + "edges": { + "N": 0, + "E": 4, + "S": 0, + "W": 2 + } + }, + { + "tile_id": 5, + "edges": { + "N": 0, + "E": 4, + "S": 9, + "W": 3 + } + }, + { + "tile_id": 6, + "edges": { + "N": 9, + "E": 3, + "S": 0, + "W": 2 + } + }, + { + "tile_id": 7, + "edges": { + "N": 0, + "E": 2, + "S": 0, + "W": 2 + } + }, + { + "tile_id": 8, + "edges": { + "N": 0, + "E": 3, + "S": 0, + "W": 3 + } + }, + { + "tile_id": 9, + "edges": { + "N": 5, + "E": 2, + "S": 5, + "W": 2 + } + }, + { + "tile_id": 10, + "edges": { + "N": 5, + "E": 3, + "S": 5, + "W": 3 + } + }, + { + "tile_id": 11, + "edges": { + "N": 0, + "E": 10, + "S": 6, + "W": 2 + } + }, + { + "tile_id": 12, + "edges": { + "N": 6, + "E": 2, + "S": 0, + "W": 10 + } + }, + { + "tile_id": 13, + "edges": { + "N": 0, + "E": 11, + "S": 6, + "W": 3 + } + }, + { + "tile_id": 14, + "edges": { + "N": 6, + "E": 3, + "S": 0, + "W": 11 + } + }, + { + "tile_id": 15, + "edges": { + "N": 6, + "E": 2, + "S": 12, + "W": 2 + } + }, + { + "tile_id": 16, + "edges": { + "N": 12, + "E": 2, + "S": 5, + "W": 2 + } + }, + { + "tile_id": 17, + "edges": { + "N": 6, + "E": 2, + "S": 13, + "W": 3 + } + }, + { + "tile_id": 18, + "edges": { + "N": 13, + "E": 3, + "S": 5, + "W": 2 + } + }, + { + "tile_id": 19, + "edges": { + "N": 6, + "E": 3, + "S": 14, + "W": 2 + } + }, + { + "tile_id": 20, + "edges": { + "N": 14, + "E": 2, + "S": 5, + "W": 3 + } + }, + { + "tile_id": 21, + "edges": { + "N": 6, + "E": 3, + "S": 15, + "W": 3 + } + }, + { + "tile_id": 22, + "edges": { + "N": 15, + "E": 3, + "S": 5, + "W": 3 + } + } + ] +} diff --git a/tests/python/test_pipeline_snapshot.py b/tests/python/test_pipeline_snapshot.py new file mode 100644 index 0000000..e392c22 --- /dev/null +++ b/tests/python/test_pipeline_snapshot.py @@ -0,0 +1,363 @@ +import copy +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from formats.pipeline_snapshot import ( + FORMULA_SCHEMA, + MANIFEST_SCHEMA, + REGION_SCHEMA, + TILESET_SCHEMA, + PipelineSnapshotError, + build_formula_snapshot, + build_region_snapshot, + build_tileset_snapshot, + dump_pipeline_snapshots, + load_explainability_bundle, + load_pipeline_snapshot, + validate_explain_manifest, + validate_formula_snapshot, + validate_region_snapshot, + validate_tileset_snapshot, +) +from model.tileset import TILESET +from native.reduction_adapter import load_formula_and_region + + +ROOT = Path(__file__).resolve().parents[2] +SAT_PATH = ROOT / "tests/instances/pipeline_sat.cm13" +SOURCE_SHA256 = hashlib.sha256(SAT_PATH.read_bytes()).hexdigest() +COMMITTED_MANIFEST = ROOT / "tests/fixtures/pipeline_sat_explain/manifest.json" +SCHEMA_PATHS = { + FORMULA_SCHEMA: ROOT / "schemas/cm13-formula-snapshot-v1.schema.json", + TILESET_SCHEMA: ROOT / "schemas/wang-tileset-snapshot-v1.schema.json", + REGION_SCHEMA: ROOT / "schemas/wang-region-snapshot-v1.schema.json", + MANIFEST_SCHEMA: ROOT / "schemas/wang-explain-manifest-v1.schema.json", +} + + +def _snapshot_paths(manifest_path: Path) -> dict[str, Path]: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + return { + "manifest": manifest_path, + **{ + name: manifest_path.parent / manifest["artifacts"][name]["path"] + for name in ("formula", "tileset", "region") + }, + } + + +class PipelineSnapshotTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.formula, cls.region = load_formula_and_region(SAT_PATH) + + def test_builders_create_closed_valid_snapshots(self) -> None: + formula = build_formula_snapshot( + self.formula, + source_name=SAT_PATH.name, + source_sha256=SOURCE_SHA256, + ) + tileset = build_tileset_snapshot() + region = build_region_snapshot( + self.region, + source_formula_sha256=SOURCE_SHA256, + origin=(-3, 5), + ) + + validate_formula_snapshot(formula) + validate_tileset_snapshot(tileset) + validate_region_snapshot(region) + self.assertEqual(formula["schema"], FORMULA_SCHEMA) + self.assertEqual(formula["variable_count"], 3) + self.assertEqual(len(formula["clauses"]), 3) + self.assertEqual(tileset["schema"], TILESET_SCHEMA) + self.assertEqual(len(tileset["tiles"]), len(TILESET)) + self.assertEqual(tileset["colors"], list(range(16))) + self.assertEqual(region["schema"], REGION_SCHEMA) + self.assertEqual(region["bounds"]["min_x_inclusive"], -3) + self.assertEqual(region["bounds"]["min_y_inclusive"], 5) + self.assertEqual(sum(region["active"]), 444) + self.assertEqual(region["active"].count(False), 7) + + with self.assertRaisesRegex(TypeError, "immutable N/E/S/W"): + build_tileset_snapshot(((0, 1),)) + + def test_publishes_four_closed_draft_2020_12_schemas(self) -> None: + for contract, path in SCHEMA_PATHS.items(): + with self.subTest(contract=contract): + schema = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual( + schema["$schema"], + "https://json-schema.org/draft/2020-12/schema", + ) + self.assertEqual(schema["properties"]["schema"]["const"], contract) + self.assertFalse(schema["additionalProperties"]) + + def test_dump_is_deterministic_and_manifest_is_hash_bound(self) -> None: + with tempfile.TemporaryDirectory() as first_directory: + first_manifest = dump_pipeline_snapshots( + Path(first_directory) / "pipeline.explain.json", + SAT_PATH, + self.formula, + self.region, + ) + first_paths = _snapshot_paths(first_manifest) + first_bytes = { + name: path.read_bytes() for name, path in first_paths.items() + } + manifest, formula, tileset, region = load_explainability_bundle( + first_manifest + ) + + with tempfile.TemporaryDirectory() as second_directory: + second_manifest = dump_pipeline_snapshots( + Path(second_directory) / "pipeline.explain.json", + SAT_PATH, + self.formula, + self.region, + ) + second_paths = _snapshot_paths(second_manifest) + second_bytes = { + name: path.read_bytes() for name, path in second_paths.items() + } + + self.assertEqual(first_bytes, second_bytes) + committed_bytes = { + name: path.read_bytes() + for name, path in _snapshot_paths(COMMITTED_MANIFEST).items() + } + self.assertEqual(first_bytes, committed_bytes) + self.assertEqual(manifest["schema"], MANIFEST_SCHEMA) + self.assertEqual(formula["source"]["sha256"], SOURCE_SHA256) + self.assertEqual(len(tileset["tiles"]), 23) + self.assertEqual(sum(region["active"]), 444) + + def test_manifest_rejects_changed_artifact_bytes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest_path = dump_pipeline_snapshots( + Path(directory) / "pipeline.explain.json", + SAT_PATH, + self.formula, + self.region, + ) + paths = _snapshot_paths(manifest_path) + paths["formula"].write_bytes(paths["formula"].read_bytes() + b" ") + with self.assertRaisesRegex(PipelineSnapshotError, "does not match"): + load_explainability_bundle(manifest_path) + + def test_bundle_rejects_cross_stage_formula_identity(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest_path = dump_pipeline_snapshots( + Path(directory) / "pipeline.explain.json", + SAT_PATH, + self.formula, + self.region, + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["source_formula_sha256"] = "0" * 64 + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with self.assertRaisesRegex( + PipelineSnapshotError, + "does not match formula artifact", + ): + load_explainability_bundle(manifest_path) + + def test_formula_rejects_bool_variable_and_noncanonical_clause_id(self) -> None: + document = build_formula_snapshot( + self.formula, + source_name=SAT_PATH.name, + source_sha256=SOURCE_SHA256, + ) + bool_variable = copy.deepcopy(document) + bool_variable["clauses"][0]["variables"][0] = True + with self.assertRaisesRegex(PipelineSnapshotError, "must be an integer"): + validate_formula_snapshot(bool_variable) + + wrong_id = copy.deepcopy(document) + wrong_id["clauses"][1]["clause_id"] = 0 + with self.assertRaisesRegex(PipelineSnapshotError, "canonical position"): + validate_formula_snapshot(wrong_id) + + def test_tileset_rejects_color_table_or_tile_id_drift(self) -> None: + document = build_tileset_snapshot() + colors = copy.deepcopy(document) + colors["colors"].append(99) + with self.assertRaisesRegex(PipelineSnapshotError, "colors used"): + validate_tileset_snapshot(colors) + + wrong_id = copy.deepcopy(document) + wrong_id["tiles"][2]["tile_id"] = 1 + with self.assertRaisesRegex(PipelineSnapshotError, "canonical table"): + validate_tileset_snapshot(wrong_id) + + def test_region_rejects_inactive_boundary_and_internal_constraint(self) -> None: + document = build_region_snapshot( + self.region, + source_formula_sha256=SOURCE_SHA256, + ) + inactive_index = document["active"].index(False) + inactive = copy.deepcopy(document) + inactive["boundary"][inactive_index] = { + "N": None, + "E": None, + "S": None, + "W": None, + } + with self.assertRaisesRegex(PipelineSnapshotError, "inactive position"): + validate_region_snapshot(inactive) + + internal = copy.deepcopy(document) + internal["boundary"][0]["E"] = 0 + with self.assertRaisesRegex(PipelineSnapshotError, "shared by active"): + validate_region_snapshot(internal) + + def test_manifest_rejects_parent_path_and_unknown_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest_path = dump_pipeline_snapshots( + Path(directory) / "pipeline.explain.json", + SAT_PATH, + self.formula, + self.region, + ) + document = json.loads(manifest_path.read_text(encoding="utf-8")) + traversal = copy.deepcopy(document) + traversal["artifacts"]["formula"]["path"] = "../formula.json" + with self.assertRaisesRegex(PipelineSnapshotError, "artifact basename"): + validate_explain_manifest(traversal) + + extra = copy.deepcopy(document) + extra["render"] = {} + with self.assertRaisesRegex(PipelineSnapshotError, "unknown fields"): + validate_explain_manifest(extra) + + def test_strict_loader_rejects_duplicate_members(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "duplicate.json" + path.write_text( + '{"schema":"wang-region-snapshot-v1","schema":"duplicate"}', + encoding="utf-8", + ) + with self.assertRaisesRegex(PipelineSnapshotError, "duplicate member"): + load_pipeline_snapshot(path) + + path.write_text('{"schema":[]}', encoding="utf-8") + with self.assertRaisesRegex( + PipelineSnapshotError, + "must be a string", + ): + load_pipeline_snapshot(path) + + def test_manifest_replace_failure_preserves_existing_output(self) -> None: + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) / "pipeline.explain.json" + destination.write_bytes(b"previous manifest") + real_replace = __import__("os").replace + + def fail_manifest_replace(source, target): + if Path(target) == destination: + raise OSError("injected manifest replace failure") + real_replace(source, target) + + with patch( + "formats.pipeline_snapshot.os.replace", + side_effect=fail_manifest_replace, + ): + with self.assertRaisesRegex( + PipelineSnapshotError, + "injected manifest replace failure", + ): + dump_pipeline_snapshots( + destination, + SAT_PATH, + self.formula, + self.region, + ) + self.assertEqual(destination.read_bytes(), b"previous manifest") + self.assertFalse(list(Path(directory).glob(".*.tmp"))) + + def test_manifest_name_collision_is_rejected_before_overwrite(self) -> None: + formula = build_formula_snapshot( + self.formula, + source_name=SAT_PATH.name, + source_sha256=SOURCE_SHA256, + ) + encoded = f"{json.dumps(formula, ensure_ascii=False, indent=2)}\n".encode() + digest = hashlib.sha256(encoded).hexdigest() + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) / f"formula-{digest}.json" + destination.write_bytes(b"existing manifest") + + with self.assertRaisesRegex(PipelineSnapshotError, "must not collide"): + dump_pipeline_snapshots( + destination, + SAT_PATH, + self.formula, + self.region, + ) + + self.assertEqual(destination.read_bytes(), b"existing manifest") + + def test_exported_bundle_loads_without_site_packages_or_native_library( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest_path = dump_pipeline_snapshots( + Path(directory) / "pipeline.explain.json", + SAT_PATH, + self.formula, + self.region, + ) + script = ( + "import sys; " + "from formats.pipeline_snapshot import load_explainability_bundle; " + "bundle=load_explainability_bundle(sys.argv[1]); " + "assert bundle[0]['schema']=='wang-explain-manifest-v1'; " + "assert 'z3' not in sys.modules and 'native._lib' not in sys.modules" + ) + environment = {"PYTHONPATH": str(ROOT / "python")} + completed = subprocess.run( + [sys.executable, "-S", "-B", "-c", script, str(manifest_path)], + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + + def test_cli_exports_the_real_formula_to_region_pipeline(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest_path = Path(directory) / "nested" / "manifest.json" + completed = subprocess.run( + [ + sys.executable, + str(ROOT / "tools/export_pipeline_snapshots.py"), + str(SAT_PATH), + str(manifest_path), + "--origin-x", + "-7", + "--origin-y", + "4", + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + bundle = load_explainability_bundle(manifest_path) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn(f"manifest={manifest_path}", completed.stdout) + self.assertEqual(bundle[1]["source"]["name"], SAT_PATH.name) + self.assertEqual(bundle[3]["bounds"]["min_x_inclusive"], -7) + self.assertEqual(bundle[3]["bounds"]["min_y_inclusive"], 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/export_pipeline_snapshots.py b/tools/export_pipeline_snapshots.py new file mode 100644 index 0000000..79febe8 --- /dev/null +++ b/tools/export_pipeline_snapshots.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Export static explainability snapshots from one real CM13 reduction.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +from formats.pipeline_snapshot import ( # noqa: E402 + dump_pipeline_snapshots, + load_pipeline_snapshot, +) +from native.reduction_adapter import load_formula_and_region # noqa: E402 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "parse a CM13 formula, build its Yang-Zhang region, and export " + "hash-bound static snapshots for the isolated renderer" + ) + ) + parser.add_argument("source", type=Path, help="input .cm13 formula") + parser.add_argument("manifest", type=Path, help="output manifest JSON") + parser.add_argument("--origin-x", type=int, default=0) + parser.add_argument("--origin-y", type=int, default=0) + return parser + + +def main(arguments: list[str] | None = None) -> int: + args = _parser().parse_args(arguments) + args.manifest.parent.mkdir(parents=True, exist_ok=True) + formula, region = load_formula_and_region(args.source) + manifest_path = dump_pipeline_snapshots( + args.manifest, + args.source, + formula, + region, + origin=(args.origin_x, args.origin_y), + ) + manifest = load_pipeline_snapshot(manifest_path) + artifacts = manifest["artifacts"] + print(f"manifest={manifest_path}") + for name in ("formula", "tileset", "region"): + artifact_path = manifest_path.parent / artifacts[name]["path"] + print(f"{name}={artifact_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())