From a5decddf71aa80bd522986bd718bf3bf2c7ab6c7 Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Thu, 27 Aug 2026 02:13:45 +0200 Subject: [PATCH 1/2] Add native reduction provenance snapshots --- README.md | 30 +- docs/development_principles.md | 18 +- docs/reduction_notes.md | 5 +- docs/wang_explainability_snapshots.md | 5 + docs/wang_reduction_explanation.md | 139 + docs/yang_zhang_builder_design.md | 35 +- include/wang/reduction_explanation.h | 59 + include/wang/yang_zhang.h | 23 +- python/formats/pipeline_snapshot.py | 617 +++- python/model/reduction_explanation.py | 213 ++ python/native/reduction_adapter.py | 14 +- python/native/region_adapter.py | 164 +- renderer/README.md | 21 +- renderer/UPSTREAM.md | 11 +- renderer/test_data/pipeline_sat_reduction.png | Bin 0 -> 68351 bytes renderer/test_wang_snapshot.py | 71 + renderer/wang_snapshot.py | 547 ++- renderer/wang_square.py | 6 +- schemas/wang-explain-manifest-v2.schema.json | 64 + .../wang-reduction-explanation-v1.schema.json | 119 + src/builder/yang_zhang.c | 178 +- tests/c/test_yang_zhang.c | 208 +- ...307fdb5fea0617de3256b717ead1af1d67815.json | 34 + .../manifest.json | 27 + ...3d76380a49bfbdcf0f1c13e923e9ec4c4f3e2.json | 329 ++ ...8a7f2f0bf27d69db3eb653208d08c5826199a.json | 3137 +++++++++++++++++ ...65a6be3bfcf59169f0acd96264831196723fa.json | 237 ++ tests/python/test_native_region.py | 32 +- tests/python/test_pipeline_snapshot.py | 143 +- .../test_reduction_explanation_model.py | 160 + tools/export_pipeline_snapshots.py | 45 +- 31 files changed, 6579 insertions(+), 112 deletions(-) create mode 100644 docs/wang_reduction_explanation.md create mode 100644 include/wang/reduction_explanation.h create mode 100644 python/model/reduction_explanation.py create mode 100644 renderer/test_data/pipeline_sat_reduction.png create mode 100644 schemas/wang-explain-manifest-v2.schema.json create mode 100644 schemas/wang-reduction-explanation-v1.schema.json create mode 100644 tests/fixtures/pipeline_sat_reduction_explain/formula-5098eb9a85adebf28757b1368e9307fdb5fea0617de3256b717ead1af1d67815.json create mode 100644 tests/fixtures/pipeline_sat_reduction_explain/manifest.json create mode 100644 tests/fixtures/pipeline_sat_reduction_explain/reduction-6abbea9333509f852d59ed9be943d76380a49bfbdcf0f1c13e923e9ec4c4f3e2.json create mode 100644 tests/fixtures/pipeline_sat_reduction_explain/region-aad4dc7dcb46ed070995bf975248a7f2f0bf27d69db3eb653208d08c5826199a.json create mode 100644 tests/fixtures/pipeline_sat_reduction_explain/tileset-462a102690c420bfe52a634e7b365a6be3bfcf59169f0acd96264831196723fa.json create mode 100644 tests/python/test_reduction_explanation_model.py diff --git a/README.md b/README.md index 273168d..64c2c0c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ solving remains future work. | 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 | +| Reduction construction provenance | Implemented as an opt-in native-owned explanation with exact signal orders, swap-bound gadget spans, manifest v2, and a square overlay view; the standard build does not allocate it | | 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 | @@ -97,13 +98,14 @@ The implemented paths are: | v | Boolean witness checker v - Yang–Zhang builder --> Region - | |\ - | | +--> static manifest --> region view - | | + tile sheet - | +----> copied Region + TILESET - | | - | v + Yang–Zhang builder --> Region + ReductionExplanation + | | |\ + | | | +--> v2 manifest --> reduction view + | | +----> v1 manifest --> region view + | | + tile sheet + | +-------> copied Region + TILESET + | | + | v native reference/ Wang Z3 optimized solver | | v @@ -151,12 +153,11 @@ diagram. Planned work remains separated into independently reviewed changes. The new priority is explainability across the deterministic pipeline: -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 +1. 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. +2. publish a compact formula-to-final-image gallery and optional technical + report from versioned examples; +3. 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 @@ -179,7 +180,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 234-test combined suite independently: +`make check`. Run its 238-test combined suite independently: ```sh cd renderer @@ -216,6 +217,9 @@ 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. +The [reduction explanation contract](docs/wang_reduction_explanation.md) +defines native signal/gadget provenance, manifest v2, ownership, replay +invariants, and the square-only construction overlay. Useful individual targets: diff --git a/docs/development_principles.md b/docs/development_principles.md index 3f34ea6..4228204 100644 --- a/docs/development_principles.md +++ b/docs/development_principles.md @@ -86,18 +86,18 @@ A module is implemented when it has: | `tile` | Fixed tileset, colors, and local matching | Search state | | `permutation` | Signal tokens and adjacent-swap generation | Region geometry | | `region` | Active cells and exposed boundary constraints | Solver domains and scheduling | -| `yang_zhang` | Reduction construction and the transferred swap trace | Solver state and duplicated permutation data | +| `yang_zhang` | Reduction construction, transferred swap trace, and immutable signal/gadget provenance | Solver state and presentation metadata | | `solver` | Domains, trail, assignments, and search state | Formula and reduction semantics | | `verify` | Stateless validation of a candidate tiling | Search logic and solver caches | | `crosscheck` | Stateless Boolean/Wang witness translation over a live reduction | Generic solver internals, clause validity, and copied swap data | | `task_plan` | Future derived scheduling data | `Region` or serial-solver ownership | -| `python/model` | Pure immutable data contracts | I/O, ctypes, Z3, and native ownership | +| `python/model` | Pure immutable formula, region, tiling, and reduction-explanation contracts | I/O, ctypes, Z3, and native ownership | | `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` | Solution and static pipeline snapshot validation and deterministic export | Native lifetimes, solving, and presentation | +| `python/formats` | Solution, static-stage, and reduction-provenance 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_snapshot.py` | Strict hash-bound snapshot consumption and static formula, tile-sheet, and region composition | Native access, solving, and semantic verification | +| `renderer/wang_snapshot.py` | Strict hash-bound formula, tile-sheet, region, and recorded reduction-provenance views | Native access, solving, and geometry reconstruction | | `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 @@ -108,8 +108,9 @@ the implemented solution contract and exporter are Python modules under ### Native and Python ownership flow The C parser and Yang–Zhang builder own native allocations. The adapters copy -complete values into immutable Python `Formula`, `Region`, tileset, and tiling -models. No ctypes pointer reaches those models or their consumers. +complete values into immutable Python `Formula`, `Region`, +`ReductionExplanation`, tileset, and tiling models. No ctypes pointer reaches +those models or their consumers. ```text C parser + Yang–Zhang builder @@ -142,8 +143,9 @@ block. The reduction coordinator parses once. While the native formula is live, it builds the corresponding `YangZhangReduction` and copies any Python views that the selected path needs. Cleanup destroys the reduction before the formula. -The swap trace stays native because no Python consumer uses it. Python models -are not reverse-marshalled into `Cm13Formula` or `Region`. +The explanation adapter copies the exact source/target signals, swap-bound +gadget spans, and region extent before cleanup. Python models are not +reverse-marshalled into `Cm13Formula` or `Region`. `native/_lib.py` centralizes lazy `libwang.so` loading. `native/reduction_adapter.py` provides the copy-only formula/region path. diff --git a/docs/reduction_notes.md b/docs/reduction_notes.md index f3cf0aa..fcfde1c 100644 --- a/docs/reduction_notes.md +++ b/docs/reduction_notes.md @@ -211,7 +211,10 @@ reduction stages: its active mask; - complete colors on every exposed side, including the staircase notches; - variable, clause, and isolated crossover boundary markers; -- transactional transfer of the exact adjacent-swap trace. +- transactional transfer of the exact adjacent-swap trace; +- immutable source/target signal and gadget-span provenance from the same + construction, as specified by the + [reduction explanation contract]({{ '/wang-reduction-explanation/' | relative_url }}). Parsing, solving, tile selection, and persistent gadget annotations belong to other modules. The diff --git a/docs/wang_explainability_snapshots.md b/docs/wang_explainability_snapshots.md index f43047f..37ba5ac 100644 --- a/docs/wang_explainability_snapshots.md +++ b/docs/wang_explainability_snapshots.md @@ -124,6 +124,11 @@ 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 next construction boundary is implemented separately by the +[reduction explanation contract]({{ '/wang-reduction-explanation/' | relative_url }}). +Its opt-in manifest v2 adds native-produced signals, adjacent-swap replay, and +gadget spans while leaving this manifest v1 and the generic `Region` unchanged. + 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 diff --git a/docs/wang_reduction_explanation.md b/docs/wang_reduction_explanation.md new file mode 100644 index 0000000..35058af --- /dev/null +++ b/docs/wang_reduction_explanation.md @@ -0,0 +1,139 @@ +--- +layout: page +title: "Yang–Zhang reduction explanation contract" +permalink: /wang-reduction-explanation/ +description: Native-produced signal, permutation, and gadget provenance for one formula-to-region construction. +section: Yang–Zhang reduction +document_kind: Data and rendering contract +status: Current implementation +updated: 2026-08-26 +nav_order: 25 +--- + +# Yang–Zhang reduction explanation contract + +The reduction builder can preserve an immutable explanation of the region it +actually constructed. This result connects formula variables and clause +positions to logical signals, the adjacent-swap program, and the half-open +rectangles occupied by the coarse gadgets. It is diagnostic provenance, not +solver input or a second implementation of the reduction. + +`Region` remains the generic square-grid model: active cells and exposed +boundary colors only. The separate `ReductionExplanation` has its own semantic +invariants, native storage, lifetime, copied Python model, JSON contract, and +renderer consumer. + +## Native result and ownership + +A successful opt-in `yang_zhang_build_explained()` owns three related results: + +- the completed `Region`; +- the exact adjacent-swap array used by the construction; +- a `ReductionExplanation` containing the exact source and target signal + arrays passed to the permutation builder and the gadget spans emitted from + the same dimensioned build. + +The standard `yang_zhang_build()` leaves `explanation` destroyed and performs +no provenance allocation, so ordinary solving and benchmarks do not pay for +this diagnostic result. The explanation arrays from the opt-in entry point are +borrowed through `YangZhangReduction` and remain +immutable after construction. `yang_zhang_reduction_destroy()` releases and +zeros all of them. Failed construction transfers nothing and leaves the output +destroyed. The Python adapter copies every value before that native cleanup; +no pointer escapes into the immutable Python model. + +This structure is not a temporary output bundle. It represents a domain object +with independent invariants and an owned lifetime, and it has an immediate +exporter and renderer consumer. + +## Signal identity and permutation + +Each signal stores its row, kind, unique token ID, and, for variable signals, +the variable and occurrence numbers. The source order has three rows per +variable and one redundant row between variable groups. The target order +follows all three ordered positions of each clause, retaining repeated +variables exactly. + +The recorded crossover gadgets are ordered by their swap ordinal. Each one +stores `swap_row`; replaying those adjacent swaps over the source token +identities must produce the target sequence exactly. Validators additionally +bind the source sequence to formula variables and the target sequence to the +referenced clause positions. + +## Gadget spans + +Every gadget uses a half-open rectangle: + +```text +[x_begin, x_end) × [y_begin, y_end) +``` + +The closed kind set is: + +| Kind | Ordinal identifies | Meaning | +| --- | --- | --- | +| `variable` | variable ID | Three-row variable input block | +| `left_forward` | always zero | Project-specific entry forwarder band | +| `crossover` | swap index | One adjacent-swap block; width is `swap_row + 1` | +| `right_forward` | always zero | Project-specific exit forwarder band | +| `clause` | clause ID | Clause-side staircase interval | + +All rectangles must lie inside the constructed region extent. Provenance does +not enter `Region`, the generic solver, the verifier, or `TaskPlan`. + +## Versioned export + +The opt-in export adds two closed Draft 2020-12 contracts: + +- `wang-reduction-explanation-v1` contains square bounds, source and target + signals, gadget spans, the source formula digest, and the exact region + artifact digest; +- `wang-explain-manifest-v2` references formula, tileset, region, and reduction + artifacts by basename, schema, and full SHA-256 digest. + +Manifest v1 remains unchanged for formula, tile-sheet, and plain region views. +Generate v2 from the real parser and builder with: + +```sh +make shared +uv run --frozen python tools/export_pipeline_snapshots.py \ + tests/instances/pipeline_sat.cm13 \ + /tmp/pipeline-sat-reduction/manifest.json \ + --reduction-explanation +``` + +The manifest is installed last and atomically. Loading verifies all artifact +hashes, formula identity, region identity, dimensions, signal populations, +formula-to-signal correspondence, crossover replay, and gadget bounds. + +## Renderer view + +The isolated renderer accepts v1 and v2 manifests. The reduction view requires +v2: + +```sh +cd renderer +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_reduction_explain/manifest.json \ + output/reduction.png \ + --view reduction +``` + +It overlays semantic gadget colors on the unassigned region, labels every +source row and clause destination, lists the parsed formula, and retains the +logical boundary-color legend. The renderer only displays recorded data; it +does not reconstruct gadget geometry or load native code. + +Reduction provenance is square-specific, so this view rejects `--hex` rather +than implying that square gadget intervals have a hex-domain meaning. The +plain region and tileset views retain their separately checked `--hex` +presentation. + +## Scope boundary + +The explanation records construction, not solving. It contains no tile +assignment, domain, propagation, decision, conflict, backtrack, runtime, or Z3 +internal order. Those belong to the separate bounded trace and report packets. +A valid explanation proves that artifacts agree with the recorded builder +output; it does not by itself prove SAT, tiling validity, or the mathematical +correctness of the reduction. diff --git a/docs/yang_zhang_builder_design.md b/docs/yang_zhang_builder_design.md index 187360b..1822d32 100644 --- a/docs/yang_zhang_builder_design.md +++ b/docs/yang_zhang_builder_design.md @@ -16,8 +16,8 @@ nav_order: 20 The builder consumes a validated canonical Cubic Monotone 1-in-3 SAT formula. It constructs the colored simply connected `Region` used by the fixed 23-tile -Yang–Zhang reduction and returns the exact adjacent-swap trace in the same -transactional result. +Yang–Zhang reduction and returns the exact adjacent-swap trace plus immutable +construction provenance in the same transactional result. This is the implementation contract for builder input validation, routing, coordinates, active geometry, exposed boundary colors, ownership, and @@ -49,7 +49,7 @@ transport, and rendering have separate contracts. ```text validated canonical Cubic Monotone 1-in-3 formula - -> colored simply connected Region + exact adjacent-swap trace + -> Region + exact adjacent-swap trace + ReductionExplanation ``` The builder performs these stages: @@ -61,7 +61,9 @@ The builder performs these stages: 4. compute the layout dimensions; 5. build the complete active mask; 6. color every exposed unit edge of the region; -7. return the region and the exact swap array transactionally. +7. retain the exact source/target signals and emit the gadget spans from those + same dimensioned construction stages; +8. return region, swaps, and explanation transactionally. Responsibilities outside the builder are: @@ -149,6 +151,8 @@ typedef struct { AdjacentSwap *swaps; size_t swap_count; + + ReductionExplanation explanation; } YangZhangReduction; bool yang_zhang_build( @@ -156,14 +160,22 @@ bool yang_zhang_build( YangZhangReduction *out_reduction ); +bool yang_zhang_build_explained( + const Cm13Formula *formula, + YangZhangReduction *out_reduction +); + void yang_zhang_reduction_destroy(YangZhangReduction *reduction); ``` The exact array returned by `yang_zhang_permutation_build()` is transferred into `YangZhangReduction`; it is not copied. It is retained as a diagnostic trace of the reduction and as possible input to later task-plan preprocessing. -The reference solver and independent verifier receive only `Region` and do not -use this logical trace. +The explanation retains the source and target arrays that were actually passed +to that permutation build and records half-open variable, forwarder, crossover, +and clause rectangles. The reference solver and independent verifier receive +only `Region` and do not use this diagnostic provenance. Its full data contract +is the [reduction explanation reference]({{ '/wang-reduction-explanation/' | relative_url }}). The output starts in the destroyed state: @@ -171,10 +183,13 @@ The output starts in the destroyed state: YangZhangReduction reduction = {0}; ``` -`yang_zhang_build()` requires a destroyed/zeroed output. On success, the caller -owns both `region.cells` and `swaps`. On every failure, the output remains in -the destroyed state. `yang_zhang_reduction_destroy()` accepts null, destroys -the region, frees the swap array, and resets every field to zero/null. +Both build entry points require a destroyed/zeroed output. The standard call +owns `region.cells` and `swaps` while leaving `explanation` destroyed, so it +does not allocate provenance. The opt-in explained call additionally owns the +arrays borrowed through `explanation`. On every failure, the output remains in +the destroyed state. +`yang_zhang_reduction_destroy()` accepts null, releases all three results, and +resets every field to zero/null. The builder is safe to call concurrently for immutable formulas when each call uses a distinct output object. It owns no global mutable state. diff --git a/include/wang/reduction_explanation.h b/include/wang/reduction_explanation.h new file mode 100644 index 0000000..af8fe09 --- /dev/null +++ b/include/wang/reduction_explanation.h @@ -0,0 +1,59 @@ +#ifndef WANG_REDUCTION_EXPLANATION_H +#define WANG_REDUCTION_EXPLANATION_H + +#include +#include + +#include "wang/permutation.h" + +/* + * Semantic kinds of the coarse Yang-Zhang gadgets built into a Region. + * These values describe construction provenance; solvers never consume them. + */ +typedef enum { + REDUCTION_GADGET_VARIABLE, + REDUCTION_GADGET_LEFT_FORWARD, + REDUCTION_GADGET_CROSSOVER, + REDUCTION_GADGET_RIGHT_FORWARD, + REDUCTION_GADGET_CLAUSE +} ReductionGadgetKind; + +#define REDUCTION_NO_SWAP_ROW UINT32_MAX + +/* + * One half-open rectangle [x_begin, x_end) x [y_begin, y_end). + * + * ordinal identifies the variable, clause, or adjacent swap for those gadget + * kinds. Both forwarder bands use ordinal zero. swap_row is meaningful only + * for REDUCTION_GADGET_CROSSOVER and otherwise equals + * REDUCTION_NO_SWAP_ROW. + */ +typedef struct { + ReductionGadgetKind kind; + uint32_t ordinal; + int32_t x_begin; + int32_t x_end; + int32_t y_begin; + int32_t y_end; + uint32_t swap_row; +} ReductionGadgetSpan; + +/* + * Immutable construction provenance owned by a successful + * YangZhangReduction returned by yang_zhang_build_explained(). + * + * source_signals and target_signals are the exact sequences passed to the + * permutation builder. Their array index is the zero-based signal row. + * gadgets records the coarse rectangles actually used to build the Region. + * Callers borrow every pointer and must not modify or release this storage. + */ +typedef struct { + SignalToken *source_signals; + SignalToken *target_signals; + size_t signal_count; + + ReductionGadgetSpan *gadgets; + size_t gadget_count; +} ReductionExplanation; + +#endif /* WANG_REDUCTION_EXPLANATION_H */ diff --git a/include/wang/yang_zhang.h b/include/wang/yang_zhang.h index e1c89b1..b5767ce 100644 --- a/include/wang/yang_zhang.h +++ b/include/wang/yang_zhang.h @@ -7,25 +7,30 @@ #include "wang/formula.h" #include "wang/permutation.h" +#include "wang/reduction_explanation.h" #include "wang/region.h" /* * Result of a Yang-Zhang reduction build. * - * On successful construction, the caller owns both region.cells and swaps. - * Initialize this object to zero before use and release it with - * yang_zhang_reduction_destroy(). + * On successful construction, the caller owns region.cells and swaps. The + * explained builder additionally owns the storage borrowed through + * explanation. Initialize this object to zero before use and release every + * owned allocation with yang_zhang_reduction_destroy(). */ typedef struct { Region region; AdjacentSwap *swaps; size_t swap_count; + + ReductionExplanation explanation; } YangZhangReduction; /* * Build the colored region and adjacent-swap trace for a canonical CM1-in-3 - * formula. The formula is borrowed and is never modified. + * formula. The formula is borrowed and is never modified. This standard path + * leaves explanation destroyed and performs no provenance allocation. * * The output must be zero-initialized or previously destroyed. Construction * is transactional: on failure, the output remains in the destroyed state. @@ -35,6 +40,16 @@ bool yang_zhang_build( YangZhangReduction *out_reduction ); +/* + * Build the same region and swap trace while also retaining immutable signal + * and gadget provenance. Geometry and swap generation share the standard + * implementation; this opt-in entry point only changes explanation ownership. + */ +bool yang_zhang_build_explained( + const Cm13Formula *formula, + YangZhangReduction *out_reduction +); + /* Release all owned storage and reset every field. Accepts NULL. */ void yang_zhang_reduction_destroy(YangZhangReduction *reduction); diff --git a/python/formats/pipeline_snapshot.py b/python/formats/pipeline_snapshot.py index b4e1f28..7acfe27 100644 --- a/python/formats/pipeline_snapshot.py +++ b/python/formats/pipeline_snapshot.py @@ -18,6 +18,17 @@ from typing import Final from model.formula import Formula +from model.reduction_explanation import ( + GADGET_CLAUSE, + GADGET_CROSSOVER, + GADGET_LEFT_FORWARD, + GADGET_RIGHT_FORWARD, + GADGET_VARIABLE, + SIGNAL_REDUNDANT, + SIGNAL_VARIABLE, + ReductionExplanation, + ReductionSignal, +) from model.region import Region from model.tileset import COLOR_NONE, TILESET, Tileset @@ -26,9 +37,22 @@ TILESET_SCHEMA: Final = "wang-tileset-snapshot-v1" REGION_SCHEMA: Final = "wang-region-snapshot-v1" MANIFEST_SCHEMA: Final = "wang-explain-manifest-v1" +REDUCTION_SCHEMA: Final = "wang-reduction-explanation-v1" +REDUCTION_MANIFEST_SCHEMA: Final = "wang-explain-manifest-v2" GEOMETRY: Final = "square" STAGE: Final = "region" +REDUCTION_STAGE: Final = "reduction" DIRECTIONS: Final = ("N", "E", "S", "W") +SIGNAL_KINDS: Final = frozenset({SIGNAL_VARIABLE, SIGNAL_REDUNDANT}) +GADGET_KINDS: Final = frozenset( + { + GADGET_VARIABLE, + GADGET_LEFT_FORWARD, + GADGET_CROSSOVER, + GADGET_RIGHT_FORWARD, + GADGET_CLAUSE, + } +) _OFFSETS: Final = ((0, -1), (1, 0), (0, 1), (-1, 0)) _SHA256_PATTERN: Final = re.compile(r"[0-9a-f]{64}") @@ -381,6 +405,231 @@ def validate_region_snapshot(document: object) -> None: ) +def _validate_explanation_signals( + value: object, + path: str, + *, + variable_count: int, + height: int, +) -> tuple[tuple[str, int, int | None, int | None], ...]: + signals = _require_array(value, path) + if len(signals) != height: + _fail(path, "length must equal the explanation height") + identities: list[tuple[str, int, int | None, int | None]] = [] + token_ids: set[int] = set() + occurrences = [0] * variable_count + redundant_count = 0 + for row, raw_signal in enumerate(signals): + item_path = f"{path}[{row}]" + signal = _require_object(raw_signal, item_path) + _require_exact_fields( + signal, + frozenset({"row", "kind", "token_id", "variable", "occurrence"}), + item_path, + ) + actual_row = _require_integer( + signal["row"], + f"{item_path}.row", + nonnegative=True, + ) + if actual_row != row: + _fail(f"{item_path}.row", f"must equal canonical row {row}") + kind = _require_string(signal["kind"], f"{item_path}.kind") + if kind not in SIGNAL_KINDS: + _fail(f"{item_path}.kind", "is not a supported signal kind") + token_id = _require_integer( + signal["token_id"], + f"{item_path}.token_id", + nonnegative=True, + ) + if token_id in token_ids: + _fail(path, "token_id values must be unique") + token_ids.add(token_id) + if kind == SIGNAL_VARIABLE: + variable = _require_integer( + signal["variable"], + f"{item_path}.variable", + nonnegative=True, + ) + occurrence = _require_integer( + signal["occurrence"], + f"{item_path}.occurrence", + nonnegative=True, + ) + if variable >= variable_count: + _fail(f"{item_path}.variable", "is outside the formula") + if occurrence >= 3: + _fail(f"{item_path}.occurrence", "must be 0, 1, or 2") + occurrences[variable] += 1 + else: + if signal["variable"] is not None or signal["occurrence"] is not None: + _fail(item_path, "redundant signal metadata must be null") + variable = None + occurrence = None + redundant_count += 1 + identities.append((kind, token_id, variable, occurrence)) + if any(count != 3 for count in occurrences): + _fail(path, "must contain three signals for every variable") + if redundant_count != variable_count - 1: + _fail(path, "has an invalid redundant signal count") + return tuple(identities) + + +def validate_reduction_explanation_snapshot(document: object) -> None: + """Validate native-produced Yang-Zhang construction provenance.""" + root = _require_object(document, "$") + _require_exact_fields( + root, + frozenset( + { + "schema", + "geometry", + "source_formula_sha256", + "region_sha256", + "variable_count", + "bounds", + "signals", + "gadgets", + } + ), + "$", + ) + _require_literal(root["schema"], REDUCTION_SCHEMA, "$.schema") + _require_literal(root["geometry"], GEOMETRY, "$.geometry") + _require_sha256(root["source_formula_sha256"], "$.source_formula_sha256") + _require_sha256(root["region_sha256"], "$.region_sha256") + variable_count = _require_integer( + root["variable_count"], + "$.variable_count", + nonnegative=True, + ) + if variable_count == 0: + _fail("$.variable_count", "must be positive") + + bounds = _require_object(root["bounds"], "$.bounds") + _require_exact_fields( + bounds, + frozenset({"x_begin", "x_end", "y_begin", "y_end"}), + "$.bounds", + ) + x_begin = _require_integer( + bounds["x_begin"], + "$.bounds.x_begin", + nonnegative=True, + ) + x_end = _require_integer( + bounds["x_end"], + "$.bounds.x_end", + nonnegative=True, + ) + y_begin = _require_integer( + bounds["y_begin"], + "$.bounds.y_begin", + nonnegative=True, + ) + y_end = _require_integer( + bounds["y_end"], + "$.bounds.y_end", + nonnegative=True, + ) + if x_begin != 0 or y_begin != 0 or x_end <= 0 or y_end <= 0: + _fail("$.bounds", "must be a nonempty zero-origin half-open box") + if y_end != 4 * variable_count - 1: + _fail("$.bounds.y_end", "must equal 4 * variable_count - 1") + + signals = _require_object(root["signals"], "$.signals") + _require_exact_fields(signals, frozenset({"source", "target"}), "$.signals") + source = _validate_explanation_signals( + signals["source"], + "$.signals.source", + variable_count=variable_count, + height=y_end, + ) + target = _validate_explanation_signals( + signals["target"], + "$.signals.target", + variable_count=variable_count, + height=y_end, + ) + if frozenset(source) != frozenset(target): + _fail("$.signals", "source and target must contain the same tokens") + + gadgets = _require_array(root["gadgets"], "$.gadgets") + populations: dict[str, list[int]] = {kind: [] for kind in GADGET_KINDS} + crossover_rows: list[int] = [] + for index, raw_gadget in enumerate(gadgets): + path = f"$.gadgets[{index}]" + gadget = _require_object(raw_gadget, path) + _require_exact_fields( + gadget, + frozenset({"kind", "ordinal", "bounds", "swap_row"}), + path, + ) + kind = _require_string(gadget["kind"], f"{path}.kind") + if kind not in GADGET_KINDS: + _fail(f"{path}.kind", "is not a supported gadget kind") + ordinal = _require_integer( + gadget["ordinal"], + f"{path}.ordinal", + nonnegative=True, + ) + populations[kind].append(ordinal) + gadget_bounds = _require_object(gadget["bounds"], f"{path}.bounds") + _require_exact_fields( + gadget_bounds, + frozenset({"x_begin", "x_end", "y_begin", "y_end"}), + f"{path}.bounds", + ) + coordinates = tuple( + _require_integer( + gadget_bounds[name], + f"{path}.bounds.{name}", + nonnegative=True, + ) + for name in ("x_begin", "x_end", "y_begin", "y_end") + ) + gx_begin, gx_end, gy_begin, gy_end = coordinates + if gx_end <= gx_begin or gy_end <= gy_begin: + _fail(f"{path}.bounds", "must be a nonempty half-open rectangle") + if gx_end > x_end or gy_end > y_end: + _fail(f"{path}.bounds", "must lie inside the explanation bounds") + if kind == GADGET_CROSSOVER: + swap_row = _require_integer( + gadget["swap_row"], + f"{path}.swap_row", + nonnegative=True, + ) + if swap_row >= y_end - 1: + _fail(f"{path}.swap_row", "is outside the signal rows") + if gx_end - gx_begin != swap_row + 1: + _fail(f"{path}.bounds", "width must equal swap_row + 1") + crossover_rows.append(swap_row) + elif gadget["swap_row"] is not None: + _fail(f"{path}.swap_row", "must be null outside crossovers") + + expected_populations = { + GADGET_VARIABLE: variable_count, + GADGET_LEFT_FORWARD: 1, + GADGET_CROSSOVER: len(crossover_rows), + GADGET_RIGHT_FORWARD: 1, + GADGET_CLAUSE: variable_count, + } + for kind, expected_count in expected_populations.items(): + if populations[kind] != list(range(expected_count)): + _fail( + "$.gadgets", + f"{kind} ordinals must equal 0..{expected_count - 1}", + ) + replay = list(source) + for swap_row in crossover_rows: + replay[swap_row], replay[swap_row + 1] = ( + replay[swap_row + 1], + replay[swap_row], + ) + if tuple(replay) != target: + _fail("$.gadgets", "crossover program does not produce target signals") + + def _validate_reference( value: object, path: str, @@ -428,6 +677,36 @@ def validate_explain_manifest(document: object) -> None: ) +def validate_reduction_explain_manifest(document: object) -> None: + """Validate the v2 manifest that adds native reduction provenance.""" + root = _require_object(document, "$") + _require_exact_fields( + root, + frozenset({"schema", "stage", "source_formula_sha256", "artifacts"}), + "$", + ) + _require_literal(root["schema"], REDUCTION_MANIFEST_SCHEMA, "$.schema") + _require_literal(root["stage"], REDUCTION_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", "reduction"}), + "$.artifacts", + ) + for name, schema in ( + ("formula", FORMULA_SCHEMA), + ("tileset", TILESET_SCHEMA), + ("region", REGION_SCHEMA), + ("reduction", REDUCTION_SCHEMA), + ): + _validate_reference( + artifacts[name], + f"$.artifacts.{name}", + expected_schema=schema, + ) + + def _reject_duplicate_members( pairs: list[tuple[str, object]], ) -> dict[str, object]: @@ -488,6 +767,8 @@ def load_pipeline_snapshot(path: str | Path) -> dict[str, object]: TILESET_SCHEMA: validate_tileset_snapshot, REGION_SCHEMA: validate_region_snapshot, MANIFEST_SCHEMA: validate_explain_manifest, + REDUCTION_SCHEMA: validate_reduction_explanation_snapshot, + REDUCTION_MANIFEST_SCHEMA: validate_reduction_explain_manifest, } validator = validators.get(schema) if validator is None: @@ -496,26 +777,25 @@ def load_pipeline_snapshot(path: str | Path) -> dict[str, object]: return document -def load_explainability_bundle( +def _load_manifest_artifacts( 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.""" + *, + manifest_schema: str, + artifact_schemas: tuple[tuple[str, str], ...], +) -> tuple[dict[str, object], dict[str, dict[str, object]]]: path = Path(manifest_path) manifest = load_pipeline_snapshot(path) - if manifest["schema"] != MANIFEST_SCHEMA: - _fail("$.schema", f"must equal {MANIFEST_SCHEMA!r}") + if manifest["schema"] != manifest_schema: + _fail("$.schema", f"must equal {manifest_schema!r}") artifacts = _require_object(manifest["artifacts"], "$.artifacts") + validators = { + FORMULA_SCHEMA: validate_formula_snapshot, + TILESET_SCHEMA: validate_tileset_snapshot, + REGION_SCHEMA: validate_region_snapshot, + REDUCTION_SCHEMA: validate_reduction_explanation_snapshot, + } loaded: dict[str, dict[str, object]] = {} - for name, schema in ( - ("formula", FORMULA_SCHEMA), - ("tileset", TILESET_SCHEMA), - ("region", REGION_SCHEMA), - ): + for name, schema in artifact_schemas: reference = _require_object(artifacts[name], f"$.artifacts.{name}") artifact_name, expected_digest = _validate_reference( reference, @@ -538,13 +818,15 @@ def load_explainability_bundle( 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) + validators[schema](document) loaded[name] = document + return manifest, loaded + +def _validate_base_bundle_identity( + manifest: dict[str, object], + loaded: dict[str, dict[str, object]], +) -> str: formula = loaded["formula"] region = loaded["region"] tileset = loaded["tileset"] @@ -572,7 +854,144 @@ def load_explainability_bundle( f"region.boundary[{index}].{direction}", "is absent from the referenced tileset color set", ) - return manifest, formula, tileset, region + return source_digest + + +def load_explainability_bundle( + manifest_path: str | Path, +) -> tuple[ + dict[str, object], + dict[str, object], + dict[str, object], + dict[str, object], +]: + """Load a v1 manifest and its three hash-bound artifacts.""" + artifact_schemas = ( + ("formula", FORMULA_SCHEMA), + ("tileset", TILESET_SCHEMA), + ("region", REGION_SCHEMA), + ) + manifest, loaded = _load_manifest_artifacts( + manifest_path, + manifest_schema=MANIFEST_SCHEMA, + artifact_schemas=artifact_schemas, + ) + _validate_base_bundle_identity(manifest, loaded) + return manifest, loaded["formula"], loaded["tileset"], loaded["region"] + + +def load_reduction_explainability_bundle( + manifest_path: str | Path, +) -> tuple[ + dict[str, object], + dict[str, object], + dict[str, object], + dict[str, object], + dict[str, object], +]: + """Load a v2 manifest and verify formula, region, and provenance identity.""" + artifact_schemas = ( + ("formula", FORMULA_SCHEMA), + ("tileset", TILESET_SCHEMA), + ("region", REGION_SCHEMA), + ("reduction", REDUCTION_SCHEMA), + ) + manifest, loaded = _load_manifest_artifacts( + manifest_path, + manifest_schema=REDUCTION_MANIFEST_SCHEMA, + artifact_schemas=artifact_schemas, + ) + source_digest = _validate_base_bundle_identity(manifest, loaded) + reduction = loaded["reduction"] + if reduction["source_formula_sha256"] != source_digest: + _fail( + "reduction.source_formula_sha256", + "does not match the manifest formula identity", + ) + artifacts = _require_object(manifest["artifacts"], "$.artifacts") + region_reference = _require_object(artifacts["region"], "$.artifacts.region") + if reduction["region_sha256"] != region_reference["sha256"]: + _fail( + "reduction.region_sha256", + "does not match the referenced region artifact", + ) + if reduction["variable_count"] != loaded["formula"]["variable_count"]: + _fail( + "reduction.variable_count", + "does not match the referenced formula", + ) + variable_count = reduction["variable_count"] + expected_source: list[tuple[str, int, int | None, int | None]] = [] + for variable in range(variable_count): + expected_source.extend( + (SIGNAL_VARIABLE, 3 * variable + occurrence, variable, occurrence) + for occurrence in range(3) + ) + if variable + 1 < variable_count: + expected_source.append( + (SIGNAL_REDUNDANT, 3 * variable_count + variable, None, None) + ) + expected_target: list[tuple[str, int, int | None, int | None]] = [] + next_occurrence = [0] * variable_count + clauses = _require_array(loaded["formula"]["clauses"], "formula.clauses") + for clause_id, raw_clause in enumerate(clauses): + clause = _require_object(raw_clause, f"formula.clauses[{clause_id}]") + variables = _require_array( + clause["variables"], + f"formula.clauses[{clause_id}].variables", + ) + for variable in variables: + occurrence = next_occurrence[variable] + next_occurrence[variable] += 1 + expected_target.append( + (SIGNAL_VARIABLE, 3 * variable + occurrence, variable, occurrence) + ) + if clause_id + 1 < variable_count: + expected_target.append( + (SIGNAL_REDUNDANT, 3 * variable_count + clause_id, None, None) + ) + signals = _require_object(reduction["signals"], "reduction.signals") + actual_sequences: list[tuple[tuple[str, int, int | None, int | None], ...]] = [] + for name in ("source", "target"): + sequence = _require_array(signals[name], f"reduction.signals.{name}") + actual_sequences.append( + tuple( + ( + item["kind"], + item["token_id"], + item["variable"], + item["occurrence"], + ) + for item in sequence + ) + ) + if actual_sequences[0] != tuple(expected_source): + _fail("reduction.signals.source", "does not match the formula variables") + if actual_sequences[1] != tuple(expected_target): + _fail("reduction.signals.target", "does not match the formula clauses") + reduction_bounds = _require_object(reduction["bounds"], "reduction.bounds") + region_bounds = _require_object(loaded["region"]["bounds"], "region.bounds") + region_width = ( + region_bounds["max_x_inclusive"] + - region_bounds["min_x_inclusive"] + + 1 + ) + region_height = ( + region_bounds["max_y_inclusive"] + - region_bounds["min_y_inclusive"] + + 1 + ) + if reduction_bounds["x_end"] != region_width: + _fail("reduction.bounds.x_end", "does not match the region width") + if reduction_bounds["y_end"] != region_height: + _fail("reduction.bounds.y_end", "does not match the region height") + return ( + manifest, + loaded["formula"], + loaded["tileset"], + loaded["region"], + reduction, + ) def build_formula_snapshot( @@ -680,6 +1099,68 @@ def build_region_snapshot( return document +def build_reduction_explanation_snapshot( + explanation: ReductionExplanation, + *, + source_formula_sha256: str, + region_sha256: str, +) -> dict[str, object]: + """Build a snapshot from copied provenance produced by the native builder.""" + if not isinstance(explanation, ReductionExplanation): + raise TypeError("explanation must be a ReductionExplanation") + _require_sha256(source_formula_sha256, "source_formula_sha256") + _require_sha256(region_sha256, "region_sha256") + + def signal_document(signal: ReductionSignal) -> dict[str, object]: + return { + "row": signal.row, + "kind": signal.kind, + "token_id": signal.token_id, + "variable": signal.variable, + "occurrence": signal.occurrence, + } + + document: dict[str, object] = { + "schema": REDUCTION_SCHEMA, + "geometry": GEOMETRY, + "source_formula_sha256": source_formula_sha256, + "region_sha256": region_sha256, + "variable_count": explanation.variable_count, + "bounds": { + "x_begin": 0, + "x_end": explanation.width, + "y_begin": 0, + "y_end": explanation.height, + }, + "signals": { + "source": [ + signal_document(signal) + for signal in explanation.source_signals + ], + "target": [ + signal_document(signal) + for signal in explanation.target_signals + ], + }, + "gadgets": [ + { + "kind": gadget.kind, + "ordinal": gadget.ordinal, + "bounds": { + "x_begin": gadget.x_begin, + "x_end": gadget.x_end, + "y_begin": gadget.y_begin, + "y_end": gadget.y_end, + }, + "swap_row": gadget.swap_row, + } + for gadget in explanation.gadgets + ], + } + validate_reduction_explanation_snapshot(document) + return document + + def _encode_document(document: dict[str, object]) -> bytes: serialized = json.dumps( document, @@ -716,17 +1197,25 @@ def _write_atomic(path: Path, encoded: bytes) -> None: pass -def dump_pipeline_snapshots( +def _dump_snapshot_bundle( manifest_path: str | Path, source_path: str | Path, formula: Formula, region: Region, *, origin: tuple[int, int] = (0, 0), + explanation: ReductionExplanation | None, ) -> Path: - """Write content-addressed artifacts, then atomically install a manifest.""" destination = Path(manifest_path) source = Path(source_path) + if explanation is not None and ( + explanation.variable_count != formula.variable_count + or explanation.width != region.width + or explanation.height != region.height + ): + raise PipelineSnapshotError( + "reduction explanation identity does not match formula and region" + ) try: source_bytes = source.read_bytes() except OSError as error: @@ -768,13 +1257,87 @@ def dump_pipeline_snapshots( "schema": schema, } + if explanation is not None: + region_reference = _require_object( + references["region"], + "references.region", + ) + explanation_document = build_reduction_explanation_snapshot( + explanation, + source_formula_sha256=source_digest, + region_sha256=region_reference["sha256"], + ) + encoded = _encode_document(explanation_document) + digest = hashlib.sha256(encoded).hexdigest() + artifact_name = f"reduction-{digest}.json" + if artifact_name == destination.name: + raise PipelineSnapshotError( + "manifest filename must not collide with a generated " + f"artifact: {artifact_name}" + ) + _write_atomic(destination.parent / artifact_name, encoded) + references["reduction"] = { + "path": artifact_name, + "sha256": digest, + "schema": REDUCTION_SCHEMA, + } + + manifest_schema = ( + REDUCTION_MANIFEST_SCHEMA if explanation is not None else MANIFEST_SCHEMA + ) + stage = REDUCTION_STAGE if explanation is not None else STAGE manifest: dict[str, object] = { - "schema": MANIFEST_SCHEMA, - "stage": STAGE, + "schema": manifest_schema, + "stage": stage, "source_formula_sha256": source_digest, "artifacts": references, } - validate_explain_manifest(manifest) + if explanation is None: + validate_explain_manifest(manifest) + else: + validate_reduction_explain_manifest(manifest) _write_atomic(destination, _encode_document(manifest)) - load_explainability_bundle(destination) + if explanation is None: + load_explainability_bundle(destination) + else: + load_reduction_explainability_bundle(destination) return destination + + +def dump_pipeline_snapshots( + manifest_path: str | Path, + source_path: str | Path, + formula: Formula, + region: Region, + *, + origin: tuple[int, int] = (0, 0), +) -> Path: + """Write v1 static artifacts, then atomically install their manifest.""" + return _dump_snapshot_bundle( + manifest_path, + source_path, + formula, + region, + origin=origin, + explanation=None, + ) + + +def dump_reduction_explanation_snapshots( + manifest_path: str | Path, + source_path: str | Path, + formula: Formula, + region: Region, + explanation: ReductionExplanation, + *, + origin: tuple[int, int] = (0, 0), +) -> Path: + """Write v2 artifacts including native construction provenance.""" + return _dump_snapshot_bundle( + manifest_path, + source_path, + formula, + region, + origin=origin, + explanation=explanation, + ) diff --git a/python/model/reduction_explanation.py b/python/model/reduction_explanation.py new file mode 100644 index 0000000..2b3aac2 --- /dev/null +++ b/python/model/reduction_explanation.py @@ -0,0 +1,213 @@ +"""Immutable provenance for one native Yang-Zhang reduction build.""" + +from dataclasses import dataclass +from typing import Final + + +SIGNAL_VARIABLE: Final = "variable" +SIGNAL_REDUNDANT: Final = "redundant" +SIGNAL_KINDS: Final = frozenset({SIGNAL_VARIABLE, SIGNAL_REDUNDANT}) + +GADGET_VARIABLE: Final = "variable" +GADGET_LEFT_FORWARD: Final = "left_forward" +GADGET_CROSSOVER: Final = "crossover" +GADGET_RIGHT_FORWARD: Final = "right_forward" +GADGET_CLAUSE: Final = "clause" +GADGET_KINDS: Final = frozenset( + { + GADGET_VARIABLE, + GADGET_LEFT_FORWARD, + GADGET_CROSSOVER, + GADGET_RIGHT_FORWARD, + GADGET_CLAUSE, + } +) + + +@dataclass(frozen=True, slots=True) +class ReductionSignal: + """One logical signal token at one zero-based row.""" + + row: int + kind: str + token_id: int + variable: int | None + occurrence: int | None + + def __post_init__(self) -> None: + if type(self.row) is not int or self.row < 0: + raise ValueError("signal row must be a nonnegative integer") + if type(self.kind) is not str or self.kind not in SIGNAL_KINDS: + raise ValueError("signal kind is invalid") + if type(self.token_id) is not int or self.token_id < 0: + raise ValueError("signal token_id must be a nonnegative integer") + if self.kind == SIGNAL_VARIABLE: + if type(self.variable) is not int or self.variable < 0: + raise ValueError("variable signal must identify a variable") + if type(self.occurrence) is not int or not 0 <= self.occurrence < 3: + raise ValueError("variable occurrence must be 0, 1, or 2") + elif self.variable is not None or self.occurrence is not None: + raise ValueError("redundant signal cannot identify a variable occurrence") + + @property + def identity(self) -> tuple[str, int, int | None, int | None]: + """Return the row-independent logical identity of this token.""" + + return (self.kind, self.token_id, self.variable, self.occurrence) + + +@dataclass(frozen=True, slots=True) +class ReductionGadget: + """One semantic gadget rectangle using half-open coordinates.""" + + kind: str + ordinal: int + x_begin: int + x_end: int + y_begin: int + y_end: int + swap_row: int | None + + def __post_init__(self) -> None: + if type(self.kind) is not str or self.kind not in GADGET_KINDS: + raise ValueError("gadget kind is invalid") + if type(self.ordinal) is not int or self.ordinal < 0: + raise ValueError("gadget ordinal must be a nonnegative integer") + for name in ("x_begin", "x_end", "y_begin", "y_end"): + if type(getattr(self, name)) is not int: + raise TypeError(f"gadget {name} must be an integer") + if self.x_begin < 0 or self.x_end <= self.x_begin: + raise ValueError("gadget x interval must be nonempty and nonnegative") + if self.y_begin < 0 or self.y_end <= self.y_begin: + raise ValueError("gadget y interval must be nonempty and nonnegative") + if self.kind == GADGET_CROSSOVER: + if type(self.swap_row) is not int or self.swap_row < 0: + raise ValueError("crossover gadget must identify its swap row") + if self.x_end - self.x_begin != self.swap_row + 1: + raise ValueError("crossover width must equal swap_row + 1") + elif self.swap_row is not None: + raise ValueError("only crossover gadgets can identify a swap row") + + +@dataclass(frozen=True, slots=True) +class ReductionExplanation: + """Copied provenance with no native pointers or rendering metadata.""" + + variable_count: int + width: int + height: int + source_signals: tuple[ReductionSignal, ...] + target_signals: tuple[ReductionSignal, ...] + gadgets: tuple[ReductionGadget, ...] + + def __post_init__(self) -> None: + if type(self.variable_count) is not int or self.variable_count <= 0: + raise ValueError("variable_count must be a positive integer") + if type(self.width) is not int or self.width <= 0: + raise ValueError("width must be a positive integer") + if type(self.height) is not int or self.height <= 0: + raise ValueError("height must be a positive integer") + if type(self.source_signals) is not tuple: + raise TypeError("source_signals must be a tuple") + if type(self.target_signals) is not tuple: + raise TypeError("target_signals must be a tuple") + if type(self.gadgets) is not tuple: + raise TypeError("gadgets must be a tuple") + if self.height != 4 * self.variable_count - 1: + raise ValueError("signal height must equal 4 * variable_count - 1") + if len(self.source_signals) != self.height: + raise ValueError("source signal count must equal the region height") + if len(self.target_signals) != self.height: + raise ValueError("target signal count must equal the region height") + + source_ids = self._validate_signal_sequence( + self.source_signals, + "source", + ) + target_ids = self._validate_signal_sequence( + self.target_signals, + "target", + ) + if source_ids != target_ids: + raise ValueError("source and target must contain the same signal tokens") + + for gadget in self.gadgets: + if type(gadget) is not ReductionGadget: + raise TypeError("gadgets must contain ReductionGadget values") + if gadget.x_end > self.width or gadget.y_end > self.height: + raise ValueError("gadget rectangle must lie inside the region bounds") + + crossovers = tuple( + gadget + for gadget in self.gadgets + if gadget.kind == GADGET_CROSSOVER + ) + if tuple(gadget.ordinal for gadget in crossovers) != tuple( + range(len(crossovers)) + ): + raise ValueError("crossover ordinals must be contiguous and ordered") + replay = [signal.identity for signal in self.source_signals] + for gadget in crossovers: + swap_row = gadget.swap_row + if swap_row is None: + raise ValueError("crossover gadget is missing its swap row") + if swap_row >= self.height - 1: + raise ValueError("crossover swap row lies outside the signal rows") + replay[swap_row], replay[swap_row + 1] = ( + replay[swap_row + 1], + replay[swap_row], + ) + if tuple(replay) != tuple( + signal.identity for signal in self.target_signals + ): + raise ValueError("crossover program does not produce target signals") + + self._validate_gadget_population(GADGET_VARIABLE, self.variable_count) + self._validate_gadget_population(GADGET_CLAUSE, self.variable_count) + self._validate_gadget_population(GADGET_LEFT_FORWARD, 1) + self._validate_gadget_population(GADGET_RIGHT_FORWARD, 1) + + def _validate_signal_sequence( + self, + signals: tuple[ReductionSignal, ...], + label: str, + ) -> frozenset[tuple[str, int, int | None, int | None]]: + identities: set[tuple[str, int, int | None, int | None]] = set() + token_ids: set[int] = set() + occurrences: list[set[int]] = [set() for _ in range(self.variable_count)] + redundant = 0 + for row, signal in enumerate(signals): + if type(signal) is not ReductionSignal: + raise TypeError(f"{label} signals must contain ReductionSignal values") + if signal.row != row: + raise ValueError(f"{label} signal rows must be contiguous and ordered") + if signal.identity in identities: + raise ValueError(f"{label} signal tokens must be unique") + if signal.token_id in token_ids: + raise ValueError(f"{label} signal token IDs must be unique") + identities.add(signal.identity) + token_ids.add(signal.token_id) + if signal.kind == SIGNAL_VARIABLE: + variable = signal.variable + occurrence = signal.occurrence + if variable is None or occurrence is None: + raise ValueError(f"{label} variable signal lacks an identity") + if variable >= self.variable_count: + raise ValueError(f"{label} signal variable is outside the formula") + occurrences[variable].add(occurrence) + else: + redundant += 1 + if any(values != {0, 1, 2} for values in occurrences): + raise ValueError( + f"{label} must contain occurrences 0, 1, and 2 per variable" + ) + if redundant != self.variable_count - 1: + raise ValueError(f"{label} has an invalid redundant signal count") + return frozenset(identities) + + def _validate_gadget_population(self, kind: str, expected: int) -> None: + gadgets = tuple(gadget for gadget in self.gadgets if gadget.kind == kind) + if len(gadgets) != expected: + raise ValueError(f"expected {expected} {kind} gadgets") + if tuple(gadget.ordinal for gadget in gadgets) != tuple(range(expected)): + raise ValueError(f"{kind} gadget ordinals must be contiguous and ordered") diff --git a/python/native/reduction_adapter.py b/python/native/reduction_adapter.py index 52bbb54..bc60ef9 100644 --- a/python/native/reduction_adapter.py +++ b/python/native/reduction_adapter.py @@ -1,9 +1,10 @@ """Coordinate one native parse into Python formula and region models.""" from model.formula import Formula +from model.reduction_explanation import ReductionExplanation from model.region import Region from native.formula_adapter import PathLike, _copy_formula, _loaded_formula -from native.region_adapter import _build_region +from native.region_adapter import _build_region, _build_region_and_explanation def load_formula_and_region(path: PathLike) -> tuple[Formula, Region]: @@ -13,3 +14,14 @@ def load_formula_and_region(path: PathLike) -> tuple[Formula, Region]: formula = _copy_formula(native_formula) region = _build_region(native_formula) return formula, region + + +def load_formula_region_and_explanation( + path: PathLike, +) -> tuple[Formula, Region, ReductionExplanation]: + """Parse once and copy the native region with its actual provenance.""" + + with _loaded_formula(path) as native_formula: + formula = _copy_formula(native_formula) + region, explanation = _build_region_and_explanation(native_formula) + return formula, region, explanation diff --git a/python/native/region_adapter.py b/python/native/region_adapter.py index 50643ef..e10470d 100644 --- a/python/native/region_adapter.py +++ b/python/native/region_adapter.py @@ -7,15 +7,28 @@ Structure, byref, c_bool, + c_int, c_int32, c_size_t, + c_uint32, c_uint8, - c_void_p, ) from functools import cache from typing import Iterator from model.region import Region +from model.reduction_explanation import ( + GADGET_CLAUSE, + GADGET_CROSSOVER, + GADGET_LEFT_FORWARD, + GADGET_RIGHT_FORWARD, + GADGET_VARIABLE, + SIGNAL_REDUNDANT, + SIGNAL_VARIABLE, + ReductionExplanation, + ReductionGadget, + ReductionSignal, +) from native._lib import library from native.formula_adapter import _Cm13Formula @@ -39,11 +52,47 @@ class _Region(Structure): ] +class _AdjacentSwap(Structure): + _fields_ = [("row", c_uint32)] + + +class _SignalToken(Structure): + _fields_ = [ + ("kind", c_int), + ("token_id", c_uint32), + ("variable", c_uint32), + ("occurrence", c_uint8), + ] + + +class _ReductionGadgetSpan(Structure): + _fields_ = [ + ("kind", c_int), + ("ordinal", c_uint32), + ("x_begin", c_int32), + ("x_end", c_int32), + ("y_begin", c_int32), + ("y_end", c_int32), + ("swap_row", c_uint32), + ] + + +class _ReductionExplanation(Structure): + _fields_ = [ + ("source_signals", POINTER(_SignalToken)), + ("target_signals", POINTER(_SignalToken)), + ("signal_count", c_size_t), + ("gadgets", POINTER(_ReductionGadgetSpan)), + ("gadget_count", c_size_t), + ] + + class _YangZhangReduction(Structure): _fields_ = [ ("region", _Region), - ("swaps", c_void_p), + ("swaps", POINTER(_AdjacentSwap)), ("swap_count", c_size_t), + ("explanation", _ReductionExplanation), ] @@ -59,6 +108,11 @@ def _region_library() -> CDLL: POINTER(_YangZhangReduction), ] lib.yang_zhang_build.restype = c_bool + lib.yang_zhang_build_explained.argtypes = [ + POINTER(_Cm13Formula), + POINTER(_YangZhangReduction), + ] + lib.yang_zhang_build_explained.restype = c_bool lib.yang_zhang_reduction_destroy.argtypes = [ POINTER(_YangZhangReduction) ] @@ -100,14 +154,105 @@ def _copy_region(native_region: _Region) -> Region: ) +def _copy_signal(native_signal: _SignalToken, row: int) -> ReductionSignal: + kind = int(native_signal.kind) + if kind == 0: + return ReductionSignal( + row=row, + kind=SIGNAL_VARIABLE, + token_id=int(native_signal.token_id), + variable=int(native_signal.variable), + occurrence=int(native_signal.occurrence), + ) + if kind == 1: + return ReductionSignal( + row=row, + kind=SIGNAL_REDUNDANT, + token_id=int(native_signal.token_id), + variable=None, + occurrence=None, + ) + raise RuntimeError(f"native explanation contains unknown signal kind {kind}") + + +_GADGET_KINDS = { + 0: GADGET_VARIABLE, + 1: GADGET_LEFT_FORWARD, + 2: GADGET_CROSSOVER, + 3: GADGET_RIGHT_FORWARD, + 4: GADGET_CLAUSE, +} +_NO_SWAP_ROW = 2**32 - 1 + + +def _copy_gadget(native_gadget: _ReductionGadgetSpan) -> ReductionGadget: + kind_value = int(native_gadget.kind) + try: + kind = _GADGET_KINDS[kind_value] + except KeyError as error: + raise RuntimeError( + f"native explanation contains unknown gadget kind {kind_value}" + ) from error + swap_row_value = int(native_gadget.swap_row) + return ReductionGadget( + kind=kind, + ordinal=int(native_gadget.ordinal), + x_begin=int(native_gadget.x_begin), + x_end=int(native_gadget.x_end), + y_begin=int(native_gadget.y_begin), + y_end=int(native_gadget.y_end), + swap_row=None if swap_row_value == _NO_SWAP_ROW else swap_row_value, + ) + + +def _copy_reduction_explanation( + native_reduction: _YangZhangReduction, + variable_count: int, +) -> ReductionExplanation: + native = native_reduction.explanation + signal_count = int(native.signal_count) + gadget_count = int(native.gadget_count) + if signal_count <= 0 or not native.source_signals or not native.target_signals: + raise RuntimeError("invalid native reduction signal storage") + if gadget_count <= 0 or not native.gadgets: + raise RuntimeError("invalid native reduction gadget storage") + source = tuple( + _copy_signal(native.source_signals[row], row) + for row in range(signal_count) + ) + target = tuple( + _copy_signal(native.target_signals[row], row) + for row in range(signal_count) + ) + gadgets = tuple( + _copy_gadget(native.gadgets[index]) + for index in range(gadget_count) + ) + return ReductionExplanation( + variable_count=variable_count, + width=int(native_reduction.region.width), + height=int(native_reduction.region.height), + source_signals=source, + target_signals=target, + gadgets=gadgets, + ) + + @contextmanager def _built_reduction( native_formula: _Cm13Formula, + *, + explained: bool = False, ) -> Iterator[_YangZhangReduction]: native_reduction = _YangZhangReduction() lib = _region_library() try: - if not lib.yang_zhang_build( + build = ( + lib.yang_zhang_build_explained + if explained + else lib.yang_zhang_build + ) + if not build( byref(native_formula), byref(native_reduction), ): @@ -120,3 +265,16 @@ def _built_reduction( def _build_region(native_formula: _Cm13Formula) -> Region: with _built_reduction(native_formula) as native_reduction: return _copy_region(native_reduction.region) + + +def _build_region_and_explanation( + native_formula: _Cm13Formula, +) -> tuple[Region, ReductionExplanation]: + with _built_reduction(native_formula, explained=True) as native_reduction: + return ( + _copy_region(native_reduction.region), + _copy_reduction_explanation( + native_reduction, + int(native_formula.variable_count), + ), + ) diff --git a/renderer/README.md b/renderer/README.md index 92edf95..8ca15be 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -118,6 +118,22 @@ square-to-hex reducer and checker before rasterization. Formula view rejects `--hex`; region view intentionally contains no assignment or partial solver state. +An opt-in `wang-explain-manifest-v2` adds the construction provenance produced +by the native Yang–Zhang builder. Render its exact source/target signals, +adjacent-swap gadget spans, formula, and unassigned region with: + +```bash +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_reduction_explain/manifest.json \ + output/reduction.png \ + --view reduction +``` + +Reduction spans describe the square construction itself, so this view rejects +`--hex`. The consumer verifies artifact hashes, formula and region identity, +the signal permutation, and gadget bounds without importing native code or +reconstructing builder geometry. + ## Input Format ### palette.json @@ -274,6 +290,7 @@ Raises `RenderingException` on pipeline errors. ├── test_data/ │ ├── wang_solution_v1_square_sat.png # Square golden for the Wang fixture │ ├── wang_solution_v1_hex_sat.png # Pointy-top hex golden for the same fixture +│ ├── pipeline_sat_reduction.png # Native reduction-provenance golden │ ├── palette_ok.json # Valid 16-color palette │ ├── palette_wrong_count.json # Only 3 colors (invalid) │ ├── palette_wrong_value.json # Component > 255 (invalid) @@ -289,8 +306,8 @@ Raises `RenderingException` on pipeline errors. uv run --locked pytest -q ``` -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 +The complete isolated suite has 238 tests: the 144 preserved legacy tests +below, 45 Wang square tests, 24 square-to-hex/hex-raster tests, and 25 static snapshot/explainability tests. To run only the original upstream suite: diff --git a/renderer/UPSTREAM.md b/renderer/UPSTREAM.md index c488675..0405b2f 100644 --- a/renderer/UPSTREAM.md +++ b/renderer/UPSTREAM.md @@ -24,9 +24,10 @@ not an upstream file. 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. +tile-sheet, unassigned-region, and native reduction-provenance views consume +hash-bound explainability manifests. These additions do not modify or import +the legacy PAP modules. They also leave `pyproject.toml`, `uv.lock`, and the +pinned dependencies unchanged. ## Verification @@ -43,8 +44,8 @@ uv run --locked pytest -q ``` The import baseline remains 144 collected tests and 144 passing tests. The -combined local suite is 234 tests: 144 preserved legacy tests, 45 Wang square -tests, 24 square-to-hex/hex-raster tests, and 21 static +combined local suite is 238 tests: 144 preserved legacy tests, 45 Wang square +tests, 24 square-to-hex/hex-raster tests, and 25 static snapshot/explainability tests. Keep the renderer environment under `renderer/.venv`; its local `.gitignore` excludes that environment and generated Python/build files. diff --git a/renderer/test_data/pipeline_sat_reduction.png b/renderer/test_data/pipeline_sat_reduction.png new file mode 100644 index 0000000000000000000000000000000000000000..0000a6cbafcad336dfeec070e1bbec925fbf07a6 GIT binary patch literal 68351 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 int: return self.max_y - self.min_y + 1 +@dataclass(frozen=True, slots=True) +class ReductionSignalSnapshot: + row: int + kind: str + token_id: int + variable: int | None + occurrence: int | None + + @property + def identity(self) -> tuple[str, int, int | None, int | None]: + return (self.kind, self.token_id, self.variable, self.occurrence) + + +@dataclass(frozen=True, slots=True) +class ReductionGadgetSnapshot: + kind: str + ordinal: int + x_begin: int + x_end: int + y_begin: int + y_end: int + swap_row: int | None + + +@dataclass(frozen=True, slots=True) +class ReductionExplanationSnapshot: + source_formula_sha256: str + region_sha256: str + variable_count: int + width: int + height: int + source_signals: tuple[ReductionSignalSnapshot, ...] + target_signals: tuple[ReductionSignalSnapshot, ...] + gadgets: tuple[ReductionGadgetSnapshot, ...] + + @dataclass(frozen=True, slots=True) class ExplainabilityBundle: source_formula_sha256: str formula: FormulaSnapshot tileset: TilesetSnapshot region: RegionSnapshot + reduction: ReductionExplanationSnapshot | None = None def _fail(path: str, message: str) -> None: @@ -453,8 +496,247 @@ def _parse_region(document: dict[str, object]) -> RegionSnapshot: ) +def _parse_reduction_signal( + value: object, + path: str, + expected_row: int, + variable_count: int, +) -> ReductionSignalSnapshot: + signal = _object(value, path) + _fields( + signal, + frozenset({"row", "kind", "token_id", "variable", "occurrence"}), + path, + ) + row = _integer(signal["row"], f"{path}.row", nonnegative=True) + if row != expected_row: + _fail(f"{path}.row", f"must equal canonical row {expected_row}") + kind = _string(signal["kind"], f"{path}.kind") + if kind not in _SIGNAL_KINDS: + _fail(f"{path}.kind", "is not a supported signal kind") + token_id = _integer( + signal["token_id"], + f"{path}.token_id", + nonnegative=True, + ) + if kind == "variable": + variable = _integer( + signal["variable"], + f"{path}.variable", + nonnegative=True, + ) + occurrence = _integer( + signal["occurrence"], + f"{path}.occurrence", + nonnegative=True, + ) + if variable >= variable_count or occurrence >= 3: + _fail(path, "variable identity is outside the formula") + else: + if signal["variable"] is not None or signal["occurrence"] is not None: + _fail(path, "redundant signal metadata must be null") + variable = None + occurrence = None + return ReductionSignalSnapshot( + row=row, + kind=kind, + token_id=token_id, + variable=variable, + occurrence=occurrence, + ) + + +def _parse_reduction(document: dict[str, object]) -> ReductionExplanationSnapshot: + _fields( + document, + frozenset( + { + "schema", + "geometry", + "source_formula_sha256", + "region_sha256", + "variable_count", + "bounds", + "signals", + "gadgets", + } + ), + "reduction $", + ) + _literal(document["schema"], REDUCTION_SCHEMA, "reduction $.schema") + _literal(document["geometry"], "square", "reduction $.geometry") + source_digest = _sha256( + document["source_formula_sha256"], + "reduction $.source_formula_sha256", + ) + region_digest = _sha256( + document["region_sha256"], + "reduction $.region_sha256", + ) + variable_count = _integer( + document["variable_count"], + "reduction $.variable_count", + nonnegative=True, + ) + if variable_count == 0: + _fail("reduction $.variable_count", "must be positive") + bounds = _object(document["bounds"], "reduction $.bounds") + _fields( + bounds, + frozenset({"x_begin", "x_end", "y_begin", "y_end"}), + "reduction $.bounds", + ) + x_begin = _integer( + bounds["x_begin"], + "reduction $.bounds.x_begin", + nonnegative=True, + ) + width = _integer( + bounds["x_end"], + "reduction $.bounds.x_end", + nonnegative=True, + ) + y_begin = _integer( + bounds["y_begin"], + "reduction $.bounds.y_begin", + nonnegative=True, + ) + height = _integer( + bounds["y_end"], + "reduction $.bounds.y_end", + nonnegative=True, + ) + if x_begin != 0 or y_begin != 0 or width <= 0: + _fail("reduction $.bounds", "must be a nonempty zero-origin box") + if height != 4 * variable_count - 1: + _fail("reduction $.bounds.y_end", "does not match variable_count") + + signals = _object(document["signals"], "reduction $.signals") + _fields(signals, frozenset({"source", "target"}), "reduction $.signals") + projected_signals: list[tuple[ReductionSignalSnapshot, ...]] = [] + for name in ("source", "target"): + raw_sequence = _array(signals[name], f"reduction $.signals.{name}") + if len(raw_sequence) != height: + _fail(f"reduction $.signals.{name}", "length must equal height") + sequence = tuple( + _parse_reduction_signal( + raw_signal, + f"reduction $.signals.{name}[{row}]", + row, + variable_count, + ) + for row, raw_signal in enumerate(raw_sequence) + ) + if len({signal.token_id for signal in sequence}) != height: + _fail(f"reduction $.signals.{name}", "token IDs must be unique") + projected_signals.append(sequence) + source, target = projected_signals + if {signal.identity for signal in source} != { + signal.identity for signal in target + }: + _fail("reduction $.signals", "source and target tokens must match") + + raw_gadgets = _array(document["gadgets"], "reduction $.gadgets") + gadgets: list[ReductionGadgetSnapshot] = [] + populations: dict[str, list[int]] = {kind: [] for kind in _GADGET_KINDS} + for index, raw_gadget in enumerate(raw_gadgets): + path = f"reduction $.gadgets[{index}]" + gadget = _object(raw_gadget, path) + _fields( + gadget, + frozenset({"kind", "ordinal", "bounds", "swap_row"}), + path, + ) + kind = _string(gadget["kind"], f"{path}.kind") + if kind not in _GADGET_KINDS: + _fail(f"{path}.kind", "is not a supported gadget kind") + ordinal = _integer( + gadget["ordinal"], + f"{path}.ordinal", + nonnegative=True, + ) + populations[kind].append(ordinal) + raw_bounds = _object(gadget["bounds"], f"{path}.bounds") + _fields( + raw_bounds, + frozenset({"x_begin", "x_end", "y_begin", "y_end"}), + f"{path}.bounds", + ) + coordinates = tuple( + _integer( + raw_bounds[name], + f"{path}.bounds.{name}", + nonnegative=True, + ) + for name in ("x_begin", "x_end", "y_begin", "y_end") + ) + gx_begin, gx_end, gy_begin, gy_end = coordinates + if gx_end <= gx_begin or gy_end <= gy_begin: + _fail(f"{path}.bounds", "must be a nonempty half-open rectangle") + if gx_end > width or gy_end > height: + _fail(f"{path}.bounds", "lies outside the reduction bounds") + if kind == "crossover": + swap_row = _integer( + gadget["swap_row"], + f"{path}.swap_row", + nonnegative=True, + ) + if swap_row >= height - 1 or gx_end - gx_begin != swap_row + 1: + _fail(path, "crossover row or width is inconsistent") + else: + if gadget["swap_row"] is not None: + _fail(f"{path}.swap_row", "must be null outside crossovers") + swap_row = None + gadgets.append( + ReductionGadgetSnapshot( + kind, + ordinal, + gx_begin, + gx_end, + gy_begin, + gy_end, + swap_row, + ) + ) + expected_counts = { + "variable": variable_count, + "left_forward": 1, + "right_forward": 1, + "clause": variable_count, + } + for kind, count in expected_counts.items(): + if populations[kind] != list(range(count)): + _fail("reduction $.gadgets", f"invalid {kind} gadget ordinals") + crossovers = tuple(gadget for gadget in gadgets if gadget.kind == "crossover") + if tuple(gadget.ordinal for gadget in crossovers) != tuple( + range(len(crossovers)) + ): + _fail("reduction $.gadgets", "invalid crossover gadget ordinals") + replay = [signal.identity for signal in source] + for crossover in crossovers: + swap_row = crossover.swap_row + if swap_row is None: + _fail("reduction $.gadgets", "crossover is missing its swap row") + replay[swap_row], replay[swap_row + 1] = ( + replay[swap_row + 1], + replay[swap_row], + ) + if tuple(replay) != tuple(signal.identity for signal in target): + _fail("reduction $.gadgets", "crossover replay does not reach target") + return ReductionExplanationSnapshot( + source_formula_sha256=source_digest, + region_sha256=region_digest, + variable_count=variable_count, + width=width, + height=height, + source_signals=source, + target_signals=target, + gadgets=tuple(gadgets), + ) + + def load_explainability_bundle(path: str | Path) -> ExplainabilityBundle: - """Load one manifest, verify hashes, and project its three artifacts.""" + """Load a v1/v2 manifest and independently verify every referenced stage.""" manifest_path = Path(path) manifest = _load_json_bytes( _read_bytes(manifest_path, "manifest"), @@ -465,20 +747,32 @@ def load_explainability_bundle(path: str | Path) -> ExplainabilityBundle: frozenset({"schema", "stage", "source_formula_sha256", "artifacts"}), "$", ) - _literal(manifest["schema"], MANIFEST_SCHEMA, "$.schema") - _literal(manifest["stage"], "region", "$.stage") + manifest_schema = _string(manifest["schema"], "$.schema") + if manifest_schema == MANIFEST_SCHEMA: + _literal(manifest["stage"], "region", "$.stage") + expected_schemas = { + "formula": FORMULA_SCHEMA, + "tileset": TILESET_SCHEMA, + "region": REGION_SCHEMA, + } + elif manifest_schema == REDUCTION_MANIFEST_SCHEMA: + _literal(manifest["stage"], "reduction", "$.stage") + expected_schemas = { + "formula": FORMULA_SCHEMA, + "tileset": TILESET_SCHEMA, + "region": REGION_SCHEMA, + "reduction": REDUCTION_SCHEMA, + } + else: + _fail("$.schema", "must be a supported explainability manifest") 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, - } + _fields(artifacts, frozenset(expected_schemas), "$.artifacts") documents: dict[str, dict[str, object]] = {} + artifact_digests: dict[str, str] = {} for name, expected_schema in expected_schemas.items(): reference_path = f"$.artifacts.{name}" reference = _object(artifacts[name], reference_path) @@ -494,6 +788,7 @@ def load_explainability_bundle(path: str | Path) -> ExplainabilityBundle: 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)) + artifact_digests[name] = expected_digest formula = _parse_formula(documents["formula"]) tileset = _parse_tileset(documents["tileset"]) @@ -502,6 +797,62 @@ def load_explainability_bundle(path: str | Path) -> ExplainabilityBundle: _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") + reduction: ReductionExplanationSnapshot | None = None + if "reduction" in documents: + reduction = _parse_reduction(documents["reduction"]) + if reduction.source_formula_sha256 != source_digest: + _fail("reduction $.source_formula_sha256", "does not match formula") + if reduction.region_sha256 != artifact_digests["region"]: + _fail("reduction $.region_sha256", "does not match region artifact") + if reduction.variable_count != formula.variable_count: + _fail("reduction $.variable_count", "does not match formula") + if (reduction.width, reduction.height) != (region.width, region.height): + _fail("reduction $.bounds", "does not match region dimensions") + expected_source: list[ + tuple[str, int, int | None, int | None] + ] = [] + for variable in range(formula.variable_count): + expected_source.extend( + ("variable", 3 * variable + occurrence, variable, occurrence) + for occurrence in range(3) + ) + if variable + 1 < formula.variable_count: + expected_source.append( + ( + "redundant", + 3 * formula.variable_count + variable, + None, + None, + ) + ) + expected_target: list[ + tuple[str, int, int | None, int | None] + ] = [] + next_occurrence = [0] * formula.variable_count + for clause_id, clause in enumerate(formula.clauses): + for variable in clause: + occurrence = next_occurrence[variable] + next_occurrence[variable] += 1 + expected_target.append( + ("variable", 3 * variable + occurrence, variable, occurrence) + ) + if clause_id + 1 < formula.variable_count: + expected_target.append( + ( + "redundant", + 3 * formula.variable_count + clause_id, + None, + None, + ) + ) + if tuple(expected_source) != tuple( + signal.identity for signal in reduction.source_signals + ): + _fail("reduction $.signals.source", "does not match formula") + if tuple(expected_target) != tuple( + signal.identity for signal in reduction.target_signals + ): + _fail("reduction $.signals.target", "does not match formula") colors = set(tileset.colors) for index, sides in enumerate(region.boundary): if sides is None: @@ -517,6 +868,7 @@ def load_explainability_bundle(path: str | Path) -> ExplainabilityBundle: formula=formula, tileset=tileset, region=region, + reduction=reduction, ) @@ -950,6 +1302,175 @@ def _compose_region_hex( return np.asarray(canvas, dtype=np.uint8) +def _signal_label(signal: ReductionSignalSnapshot) -> str: + if signal.kind == "redundant": + return f"r#{signal.token_id}" + return f"x{signal.variable}.{signal.occurrence}#{signal.token_id}" + + +def _compose_reduction( + bundle: ExplainabilityBundle, + pixels_per_cell: int, + margin: int, +) -> np.ndarray: + """Compose native gadget spans and signal routing over the built region.""" + reduction = bundle.reduction + if reduction is None: + raise WangSquareRenderError( + "reduction view requires a wang-explain-manifest-v2 bundle" + ) + 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 + left_labels = 104 + right_labels = 150 + grid_width = region.width * ppc + grid_height = region.height * ppc + side_width = max(_FORMULA_PANEL_WIDTH, _LEGEND_WIDTH) + grid_x = checked_margin + left_labels + grid_y = checked_margin + _HEADER_HEIGHT + side_x = grid_x + grid_width + right_labels + _PANEL_GAP + width = side_x + side_width + checked_margin + palette = _palette(bundle) + formula_height = 30 + len(_formula_lines(bundle.formula)) * 20 + legend_rows = (len(palette) + 1) // 2 + side_height = formula_height + 160 + legend_rows * 22 + height = max( + grid_y + grid_height + checked_margin, + grid_y + side_height + checked_margin, + ) + _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 reduction - native construction provenance", + subtitle=( + "half-open gadget spans and the exact source-to-clause " + "permutation; no tile is assigned" + ), + ) + + 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] + if sides is None: + _fail("region", "active cell is missing its boundary entry") + canvas.paste(square_region_tile(ppc, sides, palette), (x, y)) + + font = explain_font(10) + for row, (source, target) in enumerate( + zip(reduction.source_signals, reduction.target_signals, strict=True) + ): + text_y = grid_y + row * ppc + max(1, (ppc - 12) // 2) + draw.text( + (checked_margin, text_y), + _signal_label(source), + font=font, + fill=EXPLAIN_TEXT_RGB, + ) + if row % 4 < 3: + destination = f"c{row // 4}[{row % 4}]" + else: + destination = "gap" + draw.text( + (grid_x + grid_width + 8, text_y), + f"{destination} <- {_signal_label(target)}", + font=font, + fill=EXPLAIN_TEXT_RGB, + ) + + gadget_colors = { + "variable": (37, 99, 235), + "left_forward": (5, 150, 105), + "crossover": (217, 119, 6), + "right_forward": (8, 145, 178), + "clause": (220, 38, 38), + } + for gadget in reduction.gadgets: + color = gadget_colors[gadget.kind] + x0 = grid_x + gadget.x_begin * ppc + y0 = grid_y + gadget.y_begin * ppc + x1 = grid_x + gadget.x_end * ppc - 1 + y1 = grid_y + gadget.y_end * ppc - 1 + draw.rectangle((x0, y0, x1, y1), outline=color, width=3) + if gadget.kind == "crossover": + label = f"X{gadget.ordinal}:s{gadget.swap_row}" + elif gadget.kind == "variable": + label = f"v{gadget.ordinal}" + elif gadget.kind == "clause": + label = f"c{gadget.ordinal}" + elif gadget.kind == "left_forward": + label = "F-in" + else: + label = "F-out" + draw.text( + (x0 + 3, y0 + 2), + label, + font=font, + fill=color, + stroke_width=2, + stroke_fill=EXPLAIN_PANEL_RGB, + ) + + formula_end = _formula_panel( + canvas, + bundle, + (side_x, grid_y), + height - checked_margin, + ) + legend_y = formula_end + 12 + draw.text( + (side_x, legend_y), + "Native gadget spans", + font=explain_font(15), + fill=EXPLAIN_TEXT_RGB, + ) + legend_y += 25 + for kind in ( + "variable", + "left_forward", + "crossover", + "right_forward", + "clause", + ): + draw.rectangle( + (side_x, legend_y + 2, side_x + 16, legend_y + 14), + outline=gadget_colors[kind], + width=3, + ) + draw.text( + (side_x + 24, legend_y), + kind.replace("_", " "), + font=explain_font(12), + fill=EXPLAIN_TEXT_RGB, + ) + legend_y += 20 + draw_palette_legend( + draw, + palette, + (side_x, legend_y + 8), + columns=2, + ) + return np.asarray(canvas, dtype=np.uint8) + + def render_pipeline_snapshot( input_path: str | Path, output_path: str | Path, @@ -977,8 +1498,14 @@ def render_pipeline_snapshot( canvas = _compose_region_hex(bundle, pixels_per_cell, margin) else: canvas = _compose_region_square(bundle, pixels_per_cell, margin) + elif view == "reduction": + if hex_mode: + raise WangSquareRenderError( + "--hex is not meaningful for square reduction provenance" + ) + canvas = _compose_reduction(bundle, pixels_per_cell, margin) else: raise WangSquareRenderError( - "snapshot view must be one of formula, tileset, or region" + "snapshot view must be one of formula, tileset, region, or reduction" ) _save_png_atomic(canvas, output_path) diff --git a/renderer/wang_square.py b/renderer/wang_square.py index 2567b34..dda119a 100644 --- a/renderer/wang_square.py +++ b/renderer/wang_square.py @@ -945,7 +945,9 @@ def _parser() -> argparse.ArgumentParser: ) parser.add_argument( "input", - help="path to wang-solution-v1 JSON or a wang-explain-manifest-v1 file", + help=( + "path to wang-solution-v1 JSON or a wang-explain-manifest-v1/v2 file" + ), ) parser.add_argument("output", help="path to the output PNG") parser.add_argument( @@ -975,7 +977,7 @@ def _parser() -> argparse.ArgumentParser: ) parser.add_argument( "--view", - choices=("solution", "formula", "tileset", "region"), + choices=("solution", "formula", "tileset", "region", "reduction"), default="solution", help="input stage to render (default: solution)", ) diff --git a/schemas/wang-explain-manifest-v2.schema.json b/schemas/wang-explain-manifest-v2.schema.json new file mode 100644 index 0000000..afb671c --- /dev/null +++ b/schemas/wang-explain-manifest-v2.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tiling-foundry:schema:wang-explain-manifest-v2", + "title": "Tiling Foundry reduction explainability manifest v2", + "type": "object", + "additionalProperties": false, + "required": ["schema", "stage", "source_formula_sha256", "artifacts"], + "properties": { + "schema": {"const": "wang-explain-manifest-v2"}, + "stage": {"const": "reduction"}, + "source_formula_sha256": {"$ref": "#/$defs/sha256"}, + "artifacts": { + "type": "object", + "additionalProperties": false, + "required": ["formula", "tileset", "region", "reduction"], + "properties": { + "formula": {"$ref": "#/$defs/formula_reference"}, + "tileset": {"$ref": "#/$defs/tileset_reference"}, + "region": {"$ref": "#/$defs/region_reference"}, + "reduction": {"$ref": "#/$defs/reduction_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"}} + }, + "reduction_reference": { + "$ref": "#/$defs/reference", + "properties": { + "schema": {"const": "wang-reduction-explanation-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-reduction-explanation-v1.schema.json b/schemas/wang-reduction-explanation-v1.schema.json new file mode 100644 index 0000000..509ecd5 --- /dev/null +++ b/schemas/wang-reduction-explanation-v1.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tiling-foundry:schema:wang-reduction-explanation-v1", + "title": "Tiling Foundry Yang-Zhang reduction explanation v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "geometry", + "source_formula_sha256", + "region_sha256", + "variable_count", + "bounds", + "signals", + "gadgets" + ], + "properties": { + "schema": {"const": "wang-reduction-explanation-v1"}, + "geometry": {"const": "square"}, + "source_formula_sha256": {"$ref": "#/$defs/sha256"}, + "region_sha256": {"$ref": "#/$defs/sha256"}, + "variable_count": {"type": "integer", "minimum": 1}, + "bounds": {"$ref": "#/$defs/bounds"}, + "signals": { + "type": "object", + "additionalProperties": false, + "required": ["source", "target"], + "properties": { + "source": { + "type": "array", + "minItems": 3, + "items": {"$ref": "#/$defs/signal"} + }, + "target": { + "type": "array", + "minItems": 3, + "items": {"$ref": "#/$defs/signal"} + } + } + }, + "gadgets": { + "type": "array", + "minItems": 4, + "items": {"$ref": "#/$defs/gadget"} + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "bounds": { + "type": "object", + "additionalProperties": false, + "required": ["x_begin", "x_end", "y_begin", "y_end"], + "properties": { + "x_begin": {"type": "integer", "minimum": 0}, + "x_end": {"type": "integer", "minimum": 1}, + "y_begin": {"type": "integer", "minimum": 0}, + "y_end": {"type": "integer", "minimum": 1} + } + }, + "signal": { + "type": "object", + "additionalProperties": false, + "required": ["row", "kind", "token_id", "variable", "occurrence"], + "properties": { + "row": {"type": "integer", "minimum": 0}, + "kind": {"enum": ["variable", "redundant"]}, + "token_id": {"type": "integer", "minimum": 0}, + "variable": {"type": ["integer", "null"], "minimum": 0}, + "occurrence": {"type": ["integer", "null"], "minimum": 0, "maximum": 2} + }, + "allOf": [ + { + "if": {"properties": {"kind": {"const": "variable"}}}, + "then": { + "properties": { + "variable": {"type": "integer"}, + "occurrence": {"type": "integer"} + } + }, + "else": { + "properties": { + "variable": {"type": "null"}, + "occurrence": {"type": "null"} + } + } + } + ] + }, + "gadget": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "ordinal", "bounds", "swap_row"], + "properties": { + "kind": { + "enum": [ + "variable", + "left_forward", + "crossover", + "right_forward", + "clause" + ] + }, + "ordinal": {"type": "integer", "minimum": 0}, + "bounds": {"$ref": "#/$defs/bounds"}, + "swap_row": {"type": ["integer", "null"], "minimum": 0} + }, + "allOf": [ + { + "if": {"properties": {"kind": {"const": "crossover"}}}, + "then": {"properties": {"swap_row": {"type": "integer"}}}, + "else": {"properties": {"swap_row": {"type": "null"}}} + } + ] + } + } +} diff --git a/src/builder/yang_zhang.c b/src/builder/yang_zhang.c index dd9af91..72a02ce 100644 --- a/src/builder/yang_zhang.c +++ b/src/builder/yang_zhang.c @@ -10,7 +10,134 @@ static bool reduction_is_destroyed(const YangZhangReduction *reduction) && reduction->region.cell_count == 0 && reduction->region.cells == NULL && reduction->swaps == NULL - && reduction->swap_count == 0; + && reduction->swap_count == 0 + && reduction->explanation.source_signals == NULL + && reduction->explanation.target_signals == NULL + && reduction->explanation.signal_count == 0 + && reduction->explanation.gadgets == NULL + && reduction->explanation.gadget_count == 0; +} + +static bool build_reduction_explanation( + const Cm13Formula *formula, + SignalToken *source, + SignalToken *target, + size_t signal_count, + const AdjacentSwap *swaps, + size_t swap_count, + int32_t width, + int32_t height, + ReductionExplanation *out_explanation +) +{ + const size_t variable_count = formula->variable_count; + + if (variable_count > (SIZE_MAX - 2u) / 2u) { + return false; + } + + const size_t fixed_gadget_count = 2u * variable_count + 2u; + if (swap_count > SIZE_MAX - fixed_gadget_count) { + return false; + } + + const size_t gadget_count = fixed_gadget_count + swap_count; + if (gadget_count > SIZE_MAX / sizeof(*out_explanation->gadgets)) { + return false; + } + + ReductionGadgetSpan *gadgets = malloc( + gadget_count * sizeof(*gadgets) + ); + if (gadgets == NULL) { + return false; + } + + size_t gadget_index = 0; + for (uint32_t variable = 0; + variable < formula->variable_count; + ++variable) { + const int32_t first_y = (int32_t)(4u * variable); + gadgets[gadget_index++] = (ReductionGadgetSpan){ + .kind = REDUCTION_GADGET_VARIABLE, + .ordinal = variable, + .x_begin = 0, + .x_end = (int32_t)YANG_ZHANG_VARIABLE_WIDTH, + .y_begin = first_y, + .y_end = first_y + 3, + .swap_row = REDUCTION_NO_SWAP_ROW + }; + } + + const int32_t left_begin = (int32_t)YANG_ZHANG_VARIABLE_WIDTH; + const int32_t crossover_begin = left_begin + + (int32_t)YANG_ZHANG_LEFT_FORWARD_WIDTH; + gadgets[gadget_index++] = (ReductionGadgetSpan){ + .kind = REDUCTION_GADGET_LEFT_FORWARD, + .ordinal = 0, + .x_begin = left_begin, + .x_end = crossover_begin, + .y_begin = 0, + .y_end = height, + .swap_row = REDUCTION_NO_SWAP_ROW + }; + + int32_t block_x = crossover_begin; + for (size_t swap = 0; swap < swap_count; ++swap) { + const int32_t block_width = (int32_t)swaps[swap].row + 1; + gadgets[gadget_index++] = (ReductionGadgetSpan){ + .kind = REDUCTION_GADGET_CROSSOVER, + .ordinal = (uint32_t)swap, + .x_begin = block_x, + .x_end = block_x + block_width, + .y_begin = 0, + .y_end = height, + .swap_row = swaps[swap].row + }; + block_x += block_width; + } + + const int32_t right_end = block_x + + (int32_t)YANG_ZHANG_RIGHT_FORWARD_WIDTH; + gadgets[gadget_index++] = (ReductionGadgetSpan){ + .kind = REDUCTION_GADGET_RIGHT_FORWARD, + .ordinal = 0, + .x_begin = block_x, + .x_end = right_end, + .y_begin = 0, + .y_end = height, + .swap_row = REDUCTION_NO_SWAP_ROW + }; + + for (size_t clause = 0; clause < formula->clause_count; ++clause) { + const int32_t first_y = (int32_t)(4u * clause); + const int32_t y_end = clause + 1u < formula->clause_count + ? first_y + 4 + : height; + gadgets[gadget_index++] = (ReductionGadgetSpan){ + .kind = REDUCTION_GADGET_CLAUSE, + .ordinal = (uint32_t)clause, + .x_begin = right_end, + .x_end = width, + .y_begin = first_y, + .y_end = y_end, + .swap_row = REDUCTION_NO_SWAP_ROW + }; + } + + if (gadget_index != gadget_count) { + free(gadgets); + return false; + } + + *out_explanation = (ReductionExplanation){ + .source_signals = source, + .target_signals = target, + .signal_count = signal_count, + .gadgets = gadgets, + .gadget_count = gadget_count + }; + return true; } static bool formula_is_in_reduction_domain(const Cm13Formula *formula) @@ -312,9 +439,10 @@ static bool paint_crossover_boundaries( return true; } -bool yang_zhang_build( +static bool yang_zhang_build_internal( const Cm13Formula *formula, - YangZhangReduction *out_reduction + YangZhangReduction *out_reduction, + bool include_explanation ) { SignalToken *source = NULL; @@ -325,6 +453,7 @@ bool yang_zhang_build( int32_t height = 0; int32_t width = 0; Region region = {0}; + ReductionExplanation explanation = {0}; if (!reduction_is_destroyed(out_reduction) || !formula_is_in_reduction_domain(formula)) { @@ -349,9 +478,6 @@ bool yang_zhang_build( return false; } - free(target); - free(source); - if (!yang_zhang_compute_dimensions( formula->variable_count, swaps, @@ -370,18 +496,52 @@ bool yang_zhang_build( YANG_ZHANG_LEFT_FORWARD_WIDTH), swaps, swap_count - )) { + ) || + (include_explanation && !build_reduction_explanation( + formula, + source, + target, + signal_count, + swaps, + swap_count, + width, + height, + &explanation + ))) { region_destroy(®ion); free(swaps); + free(target); + free(source); return false; } out_reduction->region = region; out_reduction->swaps = swaps; out_reduction->swap_count = swap_count; + out_reduction->explanation = explanation; + if (!include_explanation) { + free(target); + free(source); + } return true; } +bool yang_zhang_build( + const Cm13Formula *formula, + YangZhangReduction *out_reduction +) +{ + return yang_zhang_build_internal(formula, out_reduction, false); +} + +bool yang_zhang_build_explained( + const Cm13Formula *formula, + YangZhangReduction *out_reduction +) +{ + return yang_zhang_build_internal(formula, out_reduction, true); +} + void yang_zhang_reduction_destroy(YangZhangReduction *reduction) { if (reduction == NULL) { @@ -392,6 +552,10 @@ void yang_zhang_reduction_destroy(YangZhangReduction *reduction) free(reduction->swaps); reduction->swaps = NULL; reduction->swap_count = 0; + free(reduction->explanation.gadgets); + free(reduction->explanation.target_signals); + free(reduction->explanation.source_signals); + reduction->explanation = (ReductionExplanation){0}; } bool yang_zhang_compute_dimensions( diff --git a/tests/c/test_yang_zhang.c b/tests/c/test_yang_zhang.c index d40ed62..782b4a0 100644 --- a/tests/c/test_yang_zhang.c +++ b/tests/c/test_yang_zhang.c @@ -21,6 +21,11 @@ static void test_reduction_destroy_accepts_null_and_empty(void) assert(reduction.region.cells == NULL); assert(reduction.swaps == NULL); assert(reduction.swap_count == 0); + assert(reduction.explanation.source_signals == NULL); + assert(reduction.explanation.target_signals == NULL); + assert(reduction.explanation.signal_count == 0); + assert(reduction.explanation.gadgets == NULL); + assert(reduction.explanation.gadget_count == 0); } static void test_reduction_destroy_releases_and_resets_owned_storage(void) @@ -32,6 +37,20 @@ static void test_reduction_destroy_releases_and_resets_owned_storage(void) reduction.swaps = malloc(2 * sizeof(*reduction.swaps)); assert(reduction.swaps != NULL); reduction.swap_count = 2; + reduction.explanation.source_signals = malloc( + 3 * sizeof(*reduction.explanation.source_signals) + ); + reduction.explanation.target_signals = malloc( + 3 * sizeof(*reduction.explanation.target_signals) + ); + reduction.explanation.gadgets = malloc( + 4 * sizeof(*reduction.explanation.gadgets) + ); + assert(reduction.explanation.source_signals != NULL); + assert(reduction.explanation.target_signals != NULL); + assert(reduction.explanation.gadgets != NULL); + reduction.explanation.signal_count = 3; + reduction.explanation.gadget_count = 4; yang_zhang_reduction_destroy(&reduction); @@ -41,6 +60,11 @@ static void test_reduction_destroy_releases_and_resets_owned_storage(void) assert(reduction.region.cells == NULL); assert(reduction.swaps == NULL); assert(reduction.swap_count == 0); + assert(reduction.explanation.source_signals == NULL); + assert(reduction.explanation.target_signals == NULL); + assert(reduction.explanation.signal_count == 0); + assert(reduction.explanation.gadgets == NULL); + assert(reduction.explanation.gadget_count == 0); /* A destroyed reduction can be destroyed repeatedly. */ yang_zhang_reduction_destroy(&reduction); @@ -65,6 +89,11 @@ static void assert_reduction_destroyed(const YangZhangReduction *reduction) assert(reduction->region.cells == NULL); assert(reduction->swaps == NULL); assert(reduction->swap_count == 0); + assert(reduction->explanation.source_signals == NULL); + assert(reduction->explanation.target_signals == NULL); + assert(reduction->explanation.signal_count == 0); + assert(reduction->explanation.gadgets == NULL); + assert(reduction->explanation.gadget_count == 0); } static ColorId expected_clause_color(int32_t y) @@ -193,6 +222,26 @@ static bool tokens_equal(const SignalToken *left, const SignalToken *right) left->occurrence == right->occurrence)); } +static void assert_gadget_span( + const ReductionGadgetSpan *gadget, + ReductionGadgetKind kind, + uint32_t ordinal, + int32_t x_begin, + int32_t x_end, + int32_t y_begin, + int32_t y_end, + uint32_t swap_row +) +{ + assert(gadget->kind == kind); + assert(gadget->ordinal == ordinal); + assert(gadget->x_begin == x_begin); + assert(gadget->x_end == x_end); + assert(gadget->y_begin == y_begin); + assert(gadget->y_end == y_end); + assert(gadget->swap_row == swap_row); +} + static void assert_build_rejected(const Cm13Formula *formula) { YangZhangReduction reduction = {0}; @@ -294,7 +343,7 @@ static void test_build_rejects_non_destroyed_output(void) assert(reduction.region.width == 1); } -static void test_build_minimal_valid_formula(void) +static void test_build_minimal_valid_formula_with_explanation(void) { Cm13Clause clauses[1]; Cm13Formula formula = one_variable_formula(clauses); @@ -303,11 +352,66 @@ static void test_build_minimal_valid_formula(void) const bool top_is_r[7] = {false}; const bool bottom_is_l[7] = {false}; - assert(yang_zhang_build(&formula, &reduction)); + assert(yang_zhang_build_explained(&formula, &reduction)); assert(reduction.region.width == 7); assert(reduction.region.height == 3); assert(reduction.swaps == NULL); assert(reduction.swap_count == 0); + assert(reduction.explanation.signal_count == 3); + assert(reduction.explanation.source_signals != NULL); + assert(reduction.explanation.target_signals != NULL); + for (uint8_t occurrence = 0; occurrence < 3; ++occurrence) { + const SignalToken expected = variable_token(0, occurrence); + assert(tokens_equal( + &reduction.explanation.source_signals[occurrence], + &expected + )); + assert(tokens_equal( + &reduction.explanation.target_signals[occurrence], + &expected + )); + } + assert(reduction.explanation.gadget_count == 4); + assert_gadget_span( + &reduction.explanation.gadgets[0], + REDUCTION_GADGET_VARIABLE, + 0, + 0, + 1, + 0, + 3, + REDUCTION_NO_SWAP_ROW + ); + assert_gadget_span( + &reduction.explanation.gadgets[1], + REDUCTION_GADGET_LEFT_FORWARD, + 0, + 1, + 3, + 0, + 3, + REDUCTION_NO_SWAP_ROW + ); + assert_gadget_span( + &reduction.explanation.gadgets[2], + REDUCTION_GADGET_RIGHT_FORWARD, + 0, + 3, + 5, + 0, + 3, + REDUCTION_NO_SWAP_ROW + ); + assert_gadget_span( + &reduction.explanation.gadgets[3], + REDUCTION_GADGET_CLAUSE, + 0, + 5, + 7, + 0, + 3, + REDUCTION_NO_SWAP_ROW + ); assert_region_encoding(&reduction.region, top_is_r, bottom_is_l); assert(memcmp(clauses, clauses_before, sizeof(clauses)) == 0); @@ -315,6 +419,25 @@ static void test_build_minimal_valid_formula(void) assert_reduction_destroyed(&reduction); } +static void test_standard_build_omits_explanation(void) +{ + Cm13Clause clauses[1]; + Cm13Formula formula = one_variable_formula(clauses); + YangZhangReduction reduction = {0}; + + assert(yang_zhang_build(&formula, &reduction)); + assert(reduction.region.width == 7); + assert(reduction.region.height == 3); + assert(reduction.explanation.source_signals == NULL); + assert(reduction.explanation.target_signals == NULL); + assert(reduction.explanation.signal_count == 0); + assert(reduction.explanation.gadgets == NULL); + assert(reduction.explanation.gadget_count == 0); + + yang_zhang_reduction_destroy(&reduction); + assert_reduction_destroyed(&reduction); +} + static void test_dimensions_normal(void) { const AdjacentSwap swaps[] = { @@ -393,10 +516,47 @@ static void test_build_paper_example(void) bool top_is_r[96] = {false}; bool bottom_is_l[96] = {false}; - assert(yang_zhang_build(&formula, &reduction)); + assert(yang_zhang_build_explained(&formula, &reduction)); assert(reduction.region.height == 11); assert(reduction.region.width == 96); assert(reduction.swap_count == ARRAY_COUNT(expected_rows)); + assert(reduction.explanation.signal_count == ARRAY_COUNT(source)); + assert(reduction.explanation.gadget_count == + 2u * formula.variable_count + 2u + ARRAY_COUNT(expected_rows)); + for (size_t i = 0; i < ARRAY_COUNT(source); ++i) { + assert(tokens_equal( + &reduction.explanation.source_signals[i], + &source[i] + )); + assert(tokens_equal( + &reduction.explanation.target_signals[i], + &target[i] + )); + } + for (uint32_t variable = 0; + variable < formula.variable_count; + ++variable) { + assert_gadget_span( + &reduction.explanation.gadgets[variable], + REDUCTION_GADGET_VARIABLE, + variable, + 0, + 1, + (int32_t)(4u * variable), + (int32_t)(4u * variable + 3u), + REDUCTION_NO_SWAP_ROW + ); + } + assert_gadget_span( + &reduction.explanation.gadgets[formula.variable_count], + REDUCTION_GADGET_LEFT_FORWARD, + 0, + 1, + 3, + 0, + 11, + REDUCTION_NO_SWAP_ROW + ); int32_t block_x = (int32_t)(YANG_ZHANG_VARIABLE_WIDTH + YANG_ZHANG_LEFT_FORWARD_WIDTH); @@ -406,6 +566,16 @@ static void test_build_paper_example(void) const int32_t block_width = (int32_t)row + 1; assert(reduction.swaps[i].row == row); + assert_gadget_span( + &reduction.explanation.gadgets[formula.variable_count + 1u + i], + REDUCTION_GADGET_CROSSOVER, + (uint32_t)i, + block_x, + block_x + block_width, + 0, + 11, + row + ); top_is_r[block_x + block_width - 1] = true; bottom_is_l[block_x] = true; block_x += block_width; @@ -414,6 +584,35 @@ static void test_build_paper_example(void) assert(crossover_width == 89); assert(block_x == 92); + const size_t right_index = + formula.variable_count + 1u + ARRAY_COUNT(expected_rows); + assert_gadget_span( + &reduction.explanation.gadgets[right_index], + REDUCTION_GADGET_RIGHT_FORWARD, + 0, + 92, + 94, + 0, + 11, + REDUCTION_NO_SWAP_ROW + ); + for (uint32_t clause = 0; clause < formula.clause_count; ++clause) { + const int32_t first_y = (int32_t)(4u * clause); + const int32_t y_end = clause + 1u < formula.clause_count + ? first_y + 4 + : reduction.region.height; + assert_gadget_span( + &reduction.explanation.gadgets[right_index + 1u + clause], + REDUCTION_GADGET_CLAUSE, + clause, + 94, + 96, + first_y, + y_end, + REDUCTION_NO_SWAP_ROW + ); + } + assert(yang_zhang_permutation_apply( source, ARRAY_COUNT(source), @@ -719,7 +918,8 @@ int main(void) test_build_rejects_variable_count_overflow(); test_failed_build_does_not_modify_formula_storage(); test_build_rejects_non_destroyed_output(); - test_build_minimal_valid_formula(); + test_build_minimal_valid_formula_with_explanation(); + test_standard_build_omits_explanation(); test_dimensions_normal(); test_build_paper_example(); diff --git a/tests/fixtures/pipeline_sat_reduction_explain/formula-5098eb9a85adebf28757b1368e9307fdb5fea0617de3256b717ead1af1d67815.json b/tests/fixtures/pipeline_sat_reduction_explain/formula-5098eb9a85adebf28757b1368e9307fdb5fea0617de3256b717ead1af1d67815.json new file mode 100644 index 0000000..e7775ba --- /dev/null +++ b/tests/fixtures/pipeline_sat_reduction_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_reduction_explain/manifest.json b/tests/fixtures/pipeline_sat_reduction_explain/manifest.json new file mode 100644 index 0000000..e1cdc6b --- /dev/null +++ b/tests/fixtures/pipeline_sat_reduction_explain/manifest.json @@ -0,0 +1,27 @@ +{ + "schema": "wang-explain-manifest-v2", + "stage": "reduction", + "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" + }, + "reduction": { + "path": "reduction-6abbea9333509f852d59ed9be943d76380a49bfbdcf0f1c13e923e9ec4c4f3e2.json", + "sha256": "6abbea9333509f852d59ed9be943d76380a49bfbdcf0f1c13e923e9ec4c4f3e2", + "schema": "wang-reduction-explanation-v1" + } + } +} diff --git a/tests/fixtures/pipeline_sat_reduction_explain/reduction-6abbea9333509f852d59ed9be943d76380a49bfbdcf0f1c13e923e9ec4c4f3e2.json b/tests/fixtures/pipeline_sat_reduction_explain/reduction-6abbea9333509f852d59ed9be943d76380a49bfbdcf0f1c13e923e9ec4c4f3e2.json new file mode 100644 index 0000000..995a489 --- /dev/null +++ b/tests/fixtures/pipeline_sat_reduction_explain/reduction-6abbea9333509f852d59ed9be943d76380a49bfbdcf0f1c13e923e9ec4c4f3e2.json @@ -0,0 +1,329 @@ +{ + "schema": "wang-reduction-explanation-v1", + "geometry": "square", + "source_formula_sha256": "3caaa6b29ac988fb4f51cc7071202d83ea1591ba6170e683b6da449cb3641542", + "region_sha256": "aad4dc7dcb46ed070995bf975248a7f2f0bf27d69db3eb653208d08c5826199a", + "variable_count": 3, + "bounds": { + "x_begin": 0, + "x_end": 41, + "y_begin": 0, + "y_end": 11 + }, + "signals": { + "source": [ + { + "row": 0, + "kind": "variable", + "token_id": 0, + "variable": 0, + "occurrence": 0 + }, + { + "row": 1, + "kind": "variable", + "token_id": 1, + "variable": 0, + "occurrence": 1 + }, + { + "row": 2, + "kind": "variable", + "token_id": 2, + "variable": 0, + "occurrence": 2 + }, + { + "row": 3, + "kind": "redundant", + "token_id": 9, + "variable": null, + "occurrence": null + }, + { + "row": 4, + "kind": "variable", + "token_id": 3, + "variable": 1, + "occurrence": 0 + }, + { + "row": 5, + "kind": "variable", + "token_id": 4, + "variable": 1, + "occurrence": 1 + }, + { + "row": 6, + "kind": "variable", + "token_id": 5, + "variable": 1, + "occurrence": 2 + }, + { + "row": 7, + "kind": "redundant", + "token_id": 10, + "variable": null, + "occurrence": null + }, + { + "row": 8, + "kind": "variable", + "token_id": 6, + "variable": 2, + "occurrence": 0 + }, + { + "row": 9, + "kind": "variable", + "token_id": 7, + "variable": 2, + "occurrence": 1 + }, + { + "row": 10, + "kind": "variable", + "token_id": 8, + "variable": 2, + "occurrence": 2 + } + ], + "target": [ + { + "row": 0, + "kind": "variable", + "token_id": 0, + "variable": 0, + "occurrence": 0 + }, + { + "row": 1, + "kind": "variable", + "token_id": 1, + "variable": 0, + "occurrence": 1 + }, + { + "row": 2, + "kind": "variable", + "token_id": 3, + "variable": 1, + "occurrence": 0 + }, + { + "row": 3, + "kind": "redundant", + "token_id": 9, + "variable": null, + "occurrence": null + }, + { + "row": 4, + "kind": "variable", + "token_id": 2, + "variable": 0, + "occurrence": 2 + }, + { + "row": 5, + "kind": "variable", + "token_id": 4, + "variable": 1, + "occurrence": 1 + }, + { + "row": 6, + "kind": "variable", + "token_id": 6, + "variable": 2, + "occurrence": 0 + }, + { + "row": 7, + "kind": "redundant", + "token_id": 10, + "variable": null, + "occurrence": null + }, + { + "row": 8, + "kind": "variable", + "token_id": 5, + "variable": 1, + "occurrence": 2 + }, + { + "row": 9, + "kind": "variable", + "token_id": 7, + "variable": 2, + "occurrence": 1 + }, + { + "row": 10, + "kind": "variable", + "token_id": 8, + "variable": 2, + "occurrence": 2 + } + ] + }, + "gadgets": [ + { + "kind": "variable", + "ordinal": 0, + "bounds": { + "x_begin": 0, + "x_end": 1, + "y_begin": 0, + "y_end": 3 + }, + "swap_row": null + }, + { + "kind": "variable", + "ordinal": 1, + "bounds": { + "x_begin": 0, + "x_end": 1, + "y_begin": 4, + "y_end": 7 + }, + "swap_row": null + }, + { + "kind": "variable", + "ordinal": 2, + "bounds": { + "x_begin": 0, + "x_end": 1, + "y_begin": 8, + "y_end": 11 + }, + "swap_row": null + }, + { + "kind": "left_forward", + "ordinal": 0, + "bounds": { + "x_begin": 1, + "x_end": 3, + "y_begin": 0, + "y_end": 11 + }, + "swap_row": null + }, + { + "kind": "crossover", + "ordinal": 0, + "bounds": { + "x_begin": 3, + "x_end": 7, + "y_begin": 0, + "y_end": 11 + }, + "swap_row": 3 + }, + { + "kind": "crossover", + "ordinal": 1, + "bounds": { + "x_begin": 7, + "x_end": 10, + "y_begin": 0, + "y_end": 11 + }, + "swap_row": 2 + }, + { + "kind": "crossover", + "ordinal": 2, + "bounds": { + "x_begin": 10, + "x_end": 14, + "y_begin": 0, + "y_end": 11 + }, + "swap_row": 3 + }, + { + "kind": "crossover", + "ordinal": 3, + "bounds": { + "x_begin": 14, + "x_end": 22, + "y_begin": 0, + "y_end": 11 + }, + "swap_row": 7 + }, + { + "kind": "crossover", + "ordinal": 4, + "bounds": { + "x_begin": 22, + "x_end": 29, + "y_begin": 0, + "y_end": 11 + }, + "swap_row": 6 + }, + { + "kind": "crossover", + "ordinal": 5, + "bounds": { + "x_begin": 29, + "x_end": 37, + "y_begin": 0, + "y_end": 11 + }, + "swap_row": 7 + }, + { + "kind": "right_forward", + "ordinal": 0, + "bounds": { + "x_begin": 37, + "x_end": 39, + "y_begin": 0, + "y_end": 11 + }, + "swap_row": null + }, + { + "kind": "clause", + "ordinal": 0, + "bounds": { + "x_begin": 39, + "x_end": 41, + "y_begin": 0, + "y_end": 4 + }, + "swap_row": null + }, + { + "kind": "clause", + "ordinal": 1, + "bounds": { + "x_begin": 39, + "x_end": 41, + "y_begin": 4, + "y_end": 8 + }, + "swap_row": null + }, + { + "kind": "clause", + "ordinal": 2, + "bounds": { + "x_begin": 39, + "x_end": 41, + "y_begin": 8, + "y_end": 11 + }, + "swap_row": null + } + ] +} diff --git a/tests/fixtures/pipeline_sat_reduction_explain/region-aad4dc7dcb46ed070995bf975248a7f2f0bf27d69db3eb653208d08c5826199a.json b/tests/fixtures/pipeline_sat_reduction_explain/region-aad4dc7dcb46ed070995bf975248a7f2f0bf27d69db3eb653208d08c5826199a.json new file mode 100644 index 0000000..fa245cd --- /dev/null +++ b/tests/fixtures/pipeline_sat_reduction_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_reduction_explain/tileset-462a102690c420bfe52a634e7b365a6be3bfcf59169f0acd96264831196723fa.json b/tests/fixtures/pipeline_sat_reduction_explain/tileset-462a102690c420bfe52a634e7b365a6be3bfcf59169f0acd96264831196723fa.json new file mode 100644 index 0000000..41d70c7 --- /dev/null +++ b/tests/fixtures/pipeline_sat_reduction_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_native_region.py b/tests/python/test_native_region.py index b39e405..e484281 100644 --- a/tests/python/test_native_region.py +++ b/tests/python/test_native_region.py @@ -12,7 +12,10 @@ _build_region, _copy_region, ) -from native.reduction_adapter import load_formula_and_region +from native.reduction_adapter import ( + load_formula_and_region, + load_formula_region_and_explanation, +) INSTANCE_DIRECTORY = Path(__file__).resolve().parents[1] / "instances" @@ -64,6 +67,33 @@ def test_loads_formula_and_builds_region_from_one_native_parse(self) -> None: 112, ) + def test_copies_native_reduction_explanation_before_cleanup(self) -> None: + formula, region, explanation = load_formula_region_and_explanation( + INSTANCE_DIRECTORY / "pipeline_sat.cm13" + ) + + self.assertEqual(explanation.variable_count, formula.variable_count) + self.assertEqual( + (explanation.width, explanation.height), + (region.width, region.height), + ) + self.assertEqual( + tuple(signal.token_id for signal in explanation.source_signals), + (0, 1, 2, 9, 3, 4, 5, 10, 6, 7, 8), + ) + self.assertEqual( + tuple(signal.token_id for signal in explanation.target_signals), + (0, 1, 3, 9, 2, 4, 6, 10, 5, 7, 8), + ) + crossovers = tuple( + gadget + for gadget in explanation.gadgets + if gadget.kind == "crossover" + ) + self.assertEqual(len(crossovers), 6) + self.assertEqual(crossovers[0].x_begin, 3) + self.assertEqual(crossovers[-1].x_end, 37) + def test_rejects_invalid_native_extent_before_copying(self) -> None: cells = (_RegionCell * 1)() invalid_regions = ( diff --git a/tests/python/test_pipeline_snapshot.py b/tests/python/test_pipeline_snapshot.py index e392c22..8c8fe98 100644 --- a/tests/python/test_pipeline_snapshot.py +++ b/tests/python/test_pipeline_snapshot.py @@ -11,22 +11,28 @@ from formats.pipeline_snapshot import ( FORMULA_SCHEMA, MANIFEST_SCHEMA, + REDUCTION_MANIFEST_SCHEMA, + REDUCTION_SCHEMA, REGION_SCHEMA, TILESET_SCHEMA, PipelineSnapshotError, build_formula_snapshot, + build_reduction_explanation_snapshot, build_region_snapshot, build_tileset_snapshot, dump_pipeline_snapshots, + dump_reduction_explanation_snapshots, load_explainability_bundle, load_pipeline_snapshot, + load_reduction_explainability_bundle, validate_explain_manifest, validate_formula_snapshot, + validate_reduction_explanation_snapshot, validate_region_snapshot, validate_tileset_snapshot, ) from model.tileset import TILESET -from native.reduction_adapter import load_formula_and_region +from native.reduction_adapter import load_formula_region_and_explanation ROOT = Path(__file__).resolve().parents[2] @@ -38,6 +44,8 @@ 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", + REDUCTION_SCHEMA: ROOT / "schemas/wang-reduction-explanation-v1.schema.json", + REDUCTION_MANIFEST_SCHEMA: ROOT / "schemas/wang-explain-manifest-v2.schema.json", } @@ -47,7 +55,7 @@ def _snapshot_paths(manifest_path: Path) -> dict[str, Path]: "manifest": manifest_path, **{ name: manifest_path.parent / manifest["artifacts"][name]["path"] - for name in ("formula", "tileset", "region") + for name in manifest["artifacts"] }, } @@ -55,7 +63,11 @@ def _snapshot_paths(manifest_path: Path) -> dict[str, Path]: class PipelineSnapshotTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.formula, cls.region = load_formula_and_region(SAT_PATH) + ( + cls.formula, + cls.region, + cls.explanation, + ) = load_formula_region_and_explanation(SAT_PATH) def test_builders_create_closed_valid_snapshots(self) -> None: formula = build_formula_snapshot( @@ -88,7 +100,7 @@ def test_builders_create_closed_valid_snapshots(self) -> None: 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: + def test_publishes_six_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")) @@ -138,6 +150,106 @@ def test_dump_is_deterministic_and_manifest_is_hash_bound(self) -> None: self.assertEqual(len(tileset["tiles"]), 23) self.assertEqual(sum(region["active"]), 444) + def test_reduction_snapshot_records_native_signals_and_gadgets(self) -> None: + document = build_reduction_explanation_snapshot( + self.explanation, + source_formula_sha256=SOURCE_SHA256, + region_sha256="0" * 64, + ) + + validate_reduction_explanation_snapshot(document) + self.assertEqual(document["schema"], REDUCTION_SCHEMA) + self.assertEqual(document["bounds"]["x_end"], self.region.width) + self.assertEqual( + [signal["token_id"] for signal in document["signals"]["target"]], + [0, 1, 3, 9, 2, 4, 6, 10, 5, 7, 8], + ) + self.assertEqual( + [ + gadget["swap_row"] + for gadget in document["gadgets"] + if gadget["kind"] == "crossover" + ], + [3, 2, 3, 7, 6, 7], + ) + + wrong_target = copy.deepcopy(document) + first = wrong_target["signals"]["target"][0] + second = wrong_target["signals"]["target"][1] + for field in ("kind", "token_id", "variable", "occurrence"): + first[field], second[field] = second[field], first[field] + with self.assertRaisesRegex(PipelineSnapshotError, "does not produce target"): + validate_reduction_explanation_snapshot(wrong_target) + + bool_ordinal = copy.deepcopy(document) + bool_ordinal["gadgets"][0]["ordinal"] = True + with self.assertRaisesRegex(PipelineSnapshotError, "must be an integer"): + validate_reduction_explanation_snapshot(bool_ordinal) + + def test_v2_bundle_is_deterministic_and_cross_stage_hash_bound(self) -> None: + snapshots: list[dict[str, bytes]] = [] + for _ in range(2): + with tempfile.TemporaryDirectory() as directory: + manifest_path = dump_reduction_explanation_snapshots( + Path(directory) / "pipeline.explain.json", + SAT_PATH, + self.formula, + self.region, + self.explanation, + origin=(-2, 4), + ) + snapshots.append( + { + name: path.read_bytes() + for name, path in _snapshot_paths(manifest_path).items() + } + ) + bundle = load_reduction_explainability_bundle(manifest_path) + + self.assertEqual(snapshots[0], snapshots[1]) + manifest, formula, tileset, region, reduction = bundle + self.assertEqual(manifest["schema"], REDUCTION_MANIFEST_SCHEMA) + self.assertEqual(formula["source"]["sha256"], SOURCE_SHA256) + self.assertEqual(len(tileset["tiles"]), 23) + self.assertEqual(region["bounds"]["min_x_inclusive"], -2) + self.assertEqual(reduction["variable_count"], 3) + + def test_v2_bundle_rejects_rebound_reduction_with_wrong_region(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest_path = dump_reduction_explanation_snapshots( + Path(directory) / "pipeline.explain.json", + SAT_PATH, + self.formula, + self.region, + self.explanation, + ) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + reference = manifest["artifacts"]["reduction"] + reduction_path = manifest_path.parent / reference["path"] + reduction = json.loads(reduction_path.read_text(encoding="utf-8")) + reduction["region_sha256"] = "0" * 64 + serialized = json.dumps( + reduction, + ensure_ascii=False, + indent=2, + ) + encoded = f"{serialized}\n".encode() + digest = hashlib.sha256(encoded).hexdigest() + replacement = manifest_path.parent / f"reduction-{digest}.json" + replacement.write_bytes(encoded) + reference["path"] = replacement.name + reference["sha256"] = digest + manifest_path.write_text( + f"{json.dumps(manifest, ensure_ascii=False, indent=2)}\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex( + PipelineSnapshotError, + "does not match the referenced region", + ): + load_reduction_explainability_bundle(manifest_path) + def test_manifest_rejects_changed_artifact_bytes(self) -> None: with tempfile.TemporaryDirectory() as directory: manifest_path = dump_pipeline_snapshots( @@ -358,6 +470,29 @@ def test_cli_exports_the_real_formula_to_region_pipeline(self) -> None: self.assertEqual(bundle[3]["bounds"]["min_x_inclusive"], -7) self.assertEqual(bundle[3]["bounds"]["min_y_inclusive"], 4) + def test_cli_opt_in_exports_native_reduction_provenance(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), + "--reduction-explanation", + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + bundle = load_reduction_explainability_bundle(manifest_path) + + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("reduction=", completed.stdout) + self.assertEqual(bundle[0]["schema"], REDUCTION_MANIFEST_SCHEMA) + self.assertEqual(bundle[4]["schema"], REDUCTION_SCHEMA) + if __name__ == "__main__": unittest.main() diff --git a/tests/python/test_reduction_explanation_model.py b/tests/python/test_reduction_explanation_model.py new file mode 100644 index 0000000..0916991 --- /dev/null +++ b/tests/python/test_reduction_explanation_model.py @@ -0,0 +1,160 @@ +from dataclasses import FrozenInstanceError, replace +import unittest + +from model.reduction_explanation import ( + GADGET_CLAUSE, + GADGET_CROSSOVER, + GADGET_LEFT_FORWARD, + GADGET_RIGHT_FORWARD, + GADGET_VARIABLE, + SIGNAL_VARIABLE, + ReductionExplanation, + ReductionGadget, + ReductionSignal, +) + + +def _signal(row: int, occurrence: int) -> ReductionSignal: + return ReductionSignal( + row=row, + kind=SIGNAL_VARIABLE, + token_id=occurrence, + variable=0, + occurrence=occurrence, + ) + + +def _explanation(*, crossover: bool = False) -> ReductionExplanation: + source = tuple(_signal(row, row) for row in range(3)) + if crossover: + target = ( + _signal(0, 1), + _signal(1, 0), + _signal(2, 2), + ) + crossover_gadgets = ( + ReductionGadget( + kind=GADGET_CROSSOVER, + ordinal=0, + x_begin=3, + x_end=4, + y_begin=0, + y_end=3, + swap_row=0, + ), + ) + right_begin = 4 + else: + target = source + crossover_gadgets = () + right_begin = 3 + gadgets = ( + ReductionGadget(GADGET_VARIABLE, 0, 0, 1, 0, 3, None), + ReductionGadget(GADGET_LEFT_FORWARD, 0, 1, 3, 0, 3, None), + *crossover_gadgets, + ReductionGadget( + GADGET_RIGHT_FORWARD, + 0, + right_begin, + right_begin + 2, + 0, + 3, + None, + ), + ReductionGadget( + GADGET_CLAUSE, + 0, + right_begin + 2, + right_begin + 4, + 0, + 3, + None, + ), + ) + return ReductionExplanation( + variable_count=1, + width=right_begin + 4, + height=3, + source_signals=source, + target_signals=target, + gadgets=gadgets, + ) + + +class ReductionExplanationModelTests(unittest.TestCase): + def test_is_immutable_and_replays_the_recorded_crossover(self) -> None: + explanation = _explanation(crossover=True) + + self.assertEqual(explanation.target_signals[0].occurrence, 1) + with self.assertRaises(FrozenInstanceError): + explanation.width = 99 # type: ignore[misc] + + def test_rejects_target_not_produced_by_the_crossover_program(self) -> None: + explanation = _explanation() + target = ( + _signal(0, 1), + _signal(1, 0), + _signal(2, 2), + ) + + with self.assertRaisesRegex(ValueError, "does not produce target"): + replace(explanation, target_signals=target) + + def test_requires_half_open_gadget_bounds_inside_the_region(self) -> None: + with self.assertRaisesRegex(ValueError, "x interval"): + ReductionGadget(GADGET_VARIABLE, 0, 1, 1, 0, 3, None) + + explanation = _explanation() + invalid = replace(explanation.gadgets[0], x_end=explanation.width + 1) + with self.assertRaisesRegex(ValueError, "inside the region"): + replace(explanation, gadgets=(invalid, *explanation.gadgets[1:])) + + def test_crossover_width_is_bound_to_its_swap_row(self) -> None: + with self.assertRaisesRegex(ValueError, "width must equal"): + ReductionGadget(GADGET_CROSSOVER, 0, 3, 5, 0, 3, 0) + + def test_rejects_missing_semantic_gadget_populations(self) -> None: + explanation = _explanation() + without_clause = tuple( + gadget + for gadget in explanation.gadgets + if gadget.kind != GADGET_CLAUSE + ) + + with self.assertRaisesRegex(ValueError, "expected 1 clause"): + replace(explanation, gadgets=without_clause) + + def test_rejects_bool_where_an_integer_is_required(self) -> None: + with self.assertRaises(ValueError): + ReductionSignal(True, SIGNAL_VARIABLE, 0, 0, 0) + + def test_requires_unique_token_ids_and_all_three_occurrences(self) -> None: + explanation = _explanation() + duplicate_id = replace(explanation.source_signals[1], token_id=0) + with self.assertRaisesRegex(ValueError, "token IDs must be unique"): + replace( + explanation, + source_signals=( + explanation.source_signals[0], + duplicate_id, + explanation.source_signals[2], + ), + ) + + repeated_occurrence = replace( + explanation.source_signals[1], + occurrence=0, + ) + with self.assertRaisesRegex(ValueError, "occurrences 0, 1, and 2"): + replace( + explanation, + source_signals=( + explanation.source_signals[0], + repeated_occurrence, + explanation.source_signals[2], + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/export_pipeline_snapshots.py b/tools/export_pipeline_snapshots.py index 79febe8..81529b5 100644 --- a/tools/export_pipeline_snapshots.py +++ b/tools/export_pipeline_snapshots.py @@ -13,9 +13,13 @@ from formats.pipeline_snapshot import ( # noqa: E402 dump_pipeline_snapshots, + dump_reduction_explanation_snapshots, load_pipeline_snapshot, ) -from native.reduction_adapter import load_formula_and_region # noqa: E402 +from native.reduction_adapter import ( # noqa: E402 + load_formula_and_region, + load_formula_region_and_explanation, +) def _parser() -> argparse.ArgumentParser: @@ -29,24 +33,45 @@ def _parser() -> argparse.ArgumentParser: 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) + parser.add_argument( + "--reduction-explanation", + action="store_true", + help="include native signal, permutation, and gadget provenance", + ) 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), - ) + if args.reduction_explanation: + formula, region, explanation = load_formula_region_and_explanation( + args.source + ) + manifest_path = dump_reduction_explanation_snapshots( + args.manifest, + args.source, + formula, + region, + explanation, + origin=(args.origin_x, args.origin_y), + ) + else: + 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_names = ["formula", "tileset", "region"] + if "reduction" in artifacts: + artifact_names.append("reduction") + for name in artifact_names: artifact_path = manifest_path.parent / artifacts[name]["path"] print(f"{name}={artifact_path}") return 0 From 578941af955c8ecd92ceb2137469ede78612da19 Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Thu, 27 Aug 2026 13:35:00 +0200 Subject: [PATCH 2/2] Harden reduction provenance ownership --- README.md | 2 +- docs/development_principles.md | 5 +- docs/wang_reduction_explanation.md | 21 ++-- docs/yang_zhang_builder_design.md | 47 +++++--- include/wang/reduction_explanation.h | 2 +- include/wang/yang_zhang.h | 28 +++-- python/formats/pipeline_snapshot.py | 125 ++++++++++++-------- python/native/region_adapter.py | 50 +++++--- src/builder/yang_zhang.c | 62 +++++++--- tests/c/test_yang_zhang.c | 155 ++++++++++++++++--------- tests/python/test_pipeline_snapshot.py | 40 +++++++ 11 files changed, 365 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index 64c2c0c..2657bf2 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ solving remains future work. | 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 | -| Reduction construction provenance | Implemented as an opt-in native-owned explanation with exact signal orders, swap-bound gadget spans, manifest v2, and a square overlay view; the standard build does not allocate it | +| Reduction construction provenance | Implemented as a separate opt-in native-owned result with exact signal orders, swap-bound gadget spans, manifest v2, and a square overlay view; the compact standard ABI does not allocate it | | 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 | diff --git a/docs/development_principles.md b/docs/development_principles.md index 4228204..5b6d419 100644 --- a/docs/development_principles.md +++ b/docs/development_principles.md @@ -141,8 +141,9 @@ positions of every clause and releases the native formula in a `finally` block. The reduction coordinator parses once. While the native formula is live, it -builds the corresponding `YangZhangReduction` and copies any Python views that -the selected path needs. Cleanup destroys the reduction before the formula. +builds either the compact `YangZhangReduction` or the separate opt-in +`YangZhangExplainedReduction`, then copies only the Python views selected by +that path. Cleanup uses the matching destructor before releasing the formula. The explanation adapter copies the exact source/target signals, swap-bound gadget spans, and region extent before cleanup. Python models are not reverse-marshalled into `Cm13Formula` or `Region`. diff --git a/docs/wang_reduction_explanation.md b/docs/wang_reduction_explanation.md index 35058af..35160f8 100644 --- a/docs/wang_reduction_explanation.md +++ b/docs/wang_reduction_explanation.md @@ -6,7 +6,7 @@ description: Native-produced signal, permutation, and gadget provenance for one section: Yang–Zhang reduction document_kind: Data and rendering contract status: Current implementation -updated: 2026-08-26 +updated: 2026-08-27 nav_order: 25 --- @@ -33,14 +33,17 @@ A successful opt-in `yang_zhang_build_explained()` owns three related results: arrays passed to the permutation builder and the gadget spans emitted from the same dimensioned build. -The standard `yang_zhang_build()` leaves `explanation` destroyed and performs -no provenance allocation, so ordinary solving and benchmarks do not pay for -this diagnostic result. The explanation arrays from the opt-in entry point are -borrowed through `YangZhangReduction` and remain -immutable after construction. `yang_zhang_reduction_destroy()` releases and -zeros all of them. Failed construction transfers nothing and leaves the output -destroyed. The Python adapter copies every value before that native cleanup; -no pointer escapes into the immutable Python model. +The standard `yang_zhang_build()` returns the original compact +`YangZhangReduction`, frees temporary signals immediately after permutation +construction, and performs no provenance allocation. Ordinary solving and +benchmarks therefore do not pay for this diagnostic result, and the public +reduction ABI does not change. The opt-in entry point instead returns a +`YangZhangExplainedReduction` containing that compact result plus the owned +explanation arrays. They remain immutable after construction and +`yang_zhang_explained_reduction_destroy()` releases and zeros both parts. +Failed construction transfers nothing and leaves the output destroyed. The +Python adapter copies every value before that native cleanup; no pointer +escapes into the immutable Python model. This structure is not a temporary output bundle. It represents a domain object with independent invariants and an owned lifetime, and it has an immediate diff --git a/docs/yang_zhang_builder_design.md b/docs/yang_zhang_builder_design.md index 1822d32..c9fde4d 100644 --- a/docs/yang_zhang_builder_design.md +++ b/docs/yang_zhang_builder_design.md @@ -6,7 +6,7 @@ description: Data flow, geometry, ownership, and tested invariants of the implem section: Yang–Zhang reduction document_kind: Implementation contract status: Current implementation -updated: 2026-08-25 +updated: 2026-08-27 nav_order: 20 --- @@ -16,8 +16,9 @@ nav_order: 20 The builder consumes a validated canonical Cubic Monotone 1-in-3 SAT formula. It constructs the colored simply connected `Region` used by the fixed 23-tile -Yang–Zhang reduction and returns the exact adjacent-swap trace plus immutable -construction provenance in the same transactional result. +Yang–Zhang reduction and returns the exact adjacent-swap trace. An opt-in +result preserves immutable construction provenance with a separate explicit +lifetime, without changing the standard reduction ABI. This is the implementation contract for builder input validation, routing, coordinates, active geometry, exposed boundary colors, ownership, and @@ -49,7 +50,8 @@ transport, and rendering have separate contracts. ```text validated canonical Cubic Monotone 1-in-3 formula - -> Region + exact adjacent-swap trace + ReductionExplanation + -> YangZhangReduction + -> optional YangZhangExplainedReduction ``` The builder performs these stages: @@ -63,7 +65,8 @@ The builder performs these stages: 6. color every exposed unit edge of the region; 7. retain the exact source/target signals and emit the gadget spans from those same dimensioned construction stages; -8. return region, swaps, and explanation transactionally. +8. return the compact standard result, or the standard result and explanation + together through the opt-in wrapper, transactionally. Responsibilities outside the builder are: @@ -151,9 +154,12 @@ typedef struct { AdjacentSwap *swaps; size_t swap_count; +} YangZhangReduction; +typedef struct { + YangZhangReduction reduction; ReductionExplanation explanation; -} YangZhangReduction; +} YangZhangExplainedReduction; bool yang_zhang_build( const Cm13Formula *formula, @@ -162,20 +168,23 @@ bool yang_zhang_build( bool yang_zhang_build_explained( const Cm13Formula *formula, - YangZhangReduction *out_reduction + YangZhangExplainedReduction *out_reduction ); void yang_zhang_reduction_destroy(YangZhangReduction *reduction); +void yang_zhang_explained_reduction_destroy( + YangZhangExplainedReduction *reduction +); ``` The exact array returned by `yang_zhang_permutation_build()` is transferred into `YangZhangReduction`; it is not copied. It is retained as a diagnostic trace of the reduction and as possible input to later task-plan preprocessing. -The explanation retains the source and target arrays that were actually passed -to that permutation build and records half-open variable, forwarder, crossover, -and clause rectangles. The reference solver and independent verifier receive -only `Region` and do not use this diagnostic provenance. Its full data contract -is the [reduction explanation reference]({{ '/wang-reduction-explanation/' | relative_url }}). +The opt-in wrapper retains the source and target arrays that were actually +passed to that permutation build and records half-open variable, forwarder, +crossover, and clause rectangles. The reference solver and independent +verifier receive only `Region` and do not use this diagnostic provenance. Its +full data contract is the [reduction explanation reference]({{ '/wang-reduction-explanation/' | relative_url }}). The output starts in the destroyed state: @@ -184,12 +193,14 @@ YangZhangReduction reduction = {0}; ``` Both build entry points require a destroyed/zeroed output. The standard call -owns `region.cells` and `swaps` while leaving `explanation` destroyed, so it -does not allocate provenance. The opt-in explained call additionally owns the -arrays borrowed through `explanation`. On every failure, the output remains in -the destroyed state. -`yang_zhang_reduction_destroy()` accepts null, releases all three results, and -resets every field to zero/null. +owns only `region.cells` and `swaps`, retains its original public layout, frees +temporary signals as soon as permutation construction finishes, and does not +allocate provenance. The opt-in call uses a zeroed +`YangZhangExplainedReduction`; its `explanation` owns the retained signals and +gadget spans. On every failure, the selected output remains destroyed. +`yang_zhang_reduction_destroy()` releases the compact result, while +`yang_zhang_explained_reduction_destroy()` releases and zeros both parts of the +opt-in wrapper. Both accept null. The builder is safe to call concurrently for immutable formulas when each call uses a distinct output object. It owns no global mutable state. diff --git a/include/wang/reduction_explanation.h b/include/wang/reduction_explanation.h index af8fe09..74403a1 100644 --- a/include/wang/reduction_explanation.h +++ b/include/wang/reduction_explanation.h @@ -40,7 +40,7 @@ typedef struct { /* * Immutable construction provenance owned by a successful - * YangZhangReduction returned by yang_zhang_build_explained(). + * YangZhangExplainedReduction returned by yang_zhang_build_explained(). * * source_signals and target_signals are the exact sequences passed to the * permutation builder. Their array index is the zero-based signal row. diff --git a/include/wang/yang_zhang.h b/include/wang/yang_zhang.h index b5767ce..13ba9e4 100644 --- a/include/wang/yang_zhang.h +++ b/include/wang/yang_zhang.h @@ -13,24 +13,31 @@ /* * Result of a Yang-Zhang reduction build. * - * On successful construction, the caller owns region.cells and swaps. The - * explained builder additionally owns the storage borrowed through - * explanation. Initialize this object to zero before use and release every - * owned allocation with yang_zhang_reduction_destroy(). + * On successful construction, the caller owns region.cells and swaps. + * Initialize this object to zero before use and release every owned allocation + * with yang_zhang_reduction_destroy(). Its layout is kept ABI-compatible with + * the original public reduction result. */ typedef struct { Region region; AdjacentSwap *swaps; size_t swap_count; +} YangZhangReduction; +/* + * Opt-in result that gives the Region build and its diagnostic provenance one + * explicit joint lifetime without changing the YangZhangReduction ABI. + */ +typedef struct { + YangZhangReduction reduction; ReductionExplanation explanation; -} YangZhangReduction; +} YangZhangExplainedReduction; /* * Build the colored region and adjacent-swap trace for a canonical CM1-in-3 * formula. The formula is borrowed and is never modified. This standard path - * leaves explanation destroyed and performs no provenance allocation. + * performs no provenance allocation. * * The output must be zero-initialized or previously destroyed. Construction * is transactional: on failure, the output remains in the destroyed state. @@ -47,12 +54,17 @@ bool yang_zhang_build( */ bool yang_zhang_build_explained( const Cm13Formula *formula, - YangZhangReduction *out_reduction + YangZhangExplainedReduction *out_reduction ); -/* Release all owned storage and reset every field. Accepts NULL. */ +/* Release standard result storage and reset every field. Accepts NULL. */ void yang_zhang_reduction_destroy(YangZhangReduction *reduction); +/* Release an opt-in explained result and reset every field. Accepts NULL. */ +void yang_zhang_explained_reduction_destroy( + YangZhangExplainedReduction *reduction +); + /* * Yang-Zhang layout conventions used by this project. * diff --git a/python/formats/pipeline_snapshot.py b/python/formats/pipeline_snapshot.py index 7acfe27..3053de1 100644 --- a/python/formats/pipeline_snapshot.py +++ b/python/formats/pipeline_snapshot.py @@ -417,7 +417,7 @@ def _validate_explanation_signals( _fail(path, "length must equal the explanation height") identities: list[tuple[str, int, int | None, int | None]] = [] token_ids: set[int] = set() - occurrences = [0] * variable_count + occurrences: list[set[int]] = [set() for _ in range(variable_count)] redundant_count = 0 for row, raw_signal in enumerate(signals): item_path = f"{path}[{row}]" @@ -460,7 +460,7 @@ def _validate_explanation_signals( _fail(f"{item_path}.variable", "is outside the formula") if occurrence >= 3: _fail(f"{item_path}.occurrence", "must be 0, 1, or 2") - occurrences[variable] += 1 + occurrences[variable].add(occurrence) else: if signal["variable"] is not None or signal["occurrence"] is not None: _fail(item_path, "redundant signal metadata must be null") @@ -468,8 +468,8 @@ def _validate_explanation_signals( occurrence = None redundant_count += 1 identities.append((kind, token_id, variable, occurrence)) - if any(count != 3 for count in occurrences): - _fail(path, "must contain three signals for every variable") + if any(values != {0, 1, 2} for values in occurrences): + _fail(path, "must contain occurrences 0, 1, and 2 for every variable") if redundant_count != variable_count - 1: _fail(path, "has an invalid redundant signal count") return tuple(identities) @@ -857,51 +857,11 @@ def _validate_base_bundle_identity( return source_digest -def load_explainability_bundle( - manifest_path: str | Path, -) -> tuple[ - dict[str, object], - dict[str, object], - dict[str, object], - dict[str, object], -]: - """Load a v1 manifest and its three hash-bound artifacts.""" - artifact_schemas = ( - ("formula", FORMULA_SCHEMA), - ("tileset", TILESET_SCHEMA), - ("region", REGION_SCHEMA), - ) - manifest, loaded = _load_manifest_artifacts( - manifest_path, - manifest_schema=MANIFEST_SCHEMA, - artifact_schemas=artifact_schemas, - ) - _validate_base_bundle_identity(manifest, loaded) - return manifest, loaded["formula"], loaded["tileset"], loaded["region"] - - -def load_reduction_explainability_bundle( - manifest_path: str | Path, -) -> tuple[ - dict[str, object], - dict[str, object], - dict[str, object], - dict[str, object], - dict[str, object], -]: - """Load a v2 manifest and verify formula, region, and provenance identity.""" - artifact_schemas = ( - ("formula", FORMULA_SCHEMA), - ("tileset", TILESET_SCHEMA), - ("region", REGION_SCHEMA), - ("reduction", REDUCTION_SCHEMA), - ) - manifest, loaded = _load_manifest_artifacts( - manifest_path, - manifest_schema=REDUCTION_MANIFEST_SCHEMA, - artifact_schemas=artifact_schemas, - ) - source_digest = _validate_base_bundle_identity(manifest, loaded) +def _validate_reduction_bundle_identity( + manifest: dict[str, object], + loaded: dict[str, dict[str, object]], + source_digest: str, +) -> None: reduction = loaded["reduction"] if reduction["source_formula_sha256"] != source_digest: _fail( @@ -951,7 +911,9 @@ def load_reduction_explainability_bundle( (SIGNAL_REDUNDANT, 3 * variable_count + clause_id, None, None) ) signals = _require_object(reduction["signals"], "reduction.signals") - actual_sequences: list[tuple[tuple[str, int, int | None, int | None], ...]] = [] + actual_sequences: list[ + tuple[tuple[str, int, int | None, int | None], ...] + ] = [] for name in ("source", "target"): sequence = _require_array(signals[name], f"reduction.signals.{name}") actual_sequences.append( @@ -985,6 +947,55 @@ def load_reduction_explainability_bundle( _fail("reduction.bounds.x_end", "does not match the region width") if reduction_bounds["y_end"] != region_height: _fail("reduction.bounds.y_end", "does not match the region height") + + +def load_explainability_bundle( + manifest_path: str | Path, +) -> tuple[ + dict[str, object], + dict[str, object], + dict[str, object], + dict[str, object], +]: + """Load a v1 manifest and its three hash-bound artifacts.""" + artifact_schemas = ( + ("formula", FORMULA_SCHEMA), + ("tileset", TILESET_SCHEMA), + ("region", REGION_SCHEMA), + ) + manifest, loaded = _load_manifest_artifacts( + manifest_path, + manifest_schema=MANIFEST_SCHEMA, + artifact_schemas=artifact_schemas, + ) + _validate_base_bundle_identity(manifest, loaded) + return manifest, loaded["formula"], loaded["tileset"], loaded["region"] + + +def load_reduction_explainability_bundle( + manifest_path: str | Path, +) -> tuple[ + dict[str, object], + dict[str, object], + dict[str, object], + dict[str, object], + dict[str, object], +]: + """Load a v2 manifest and verify formula, region, and provenance identity.""" + artifact_schemas = ( + ("formula", FORMULA_SCHEMA), + ("tileset", TILESET_SCHEMA), + ("region", REGION_SCHEMA), + ("reduction", REDUCTION_SCHEMA), + ) + manifest, loaded = _load_manifest_artifacts( + manifest_path, + manifest_schema=REDUCTION_MANIFEST_SCHEMA, + artifact_schemas=artifact_schemas, + ) + source_digest = _validate_base_bundle_identity(manifest, loaded) + _validate_reduction_bundle_identity(manifest, loaded, source_digest) + reduction = loaded["reduction"] return ( manifest, loaded["formula"], @@ -1239,6 +1250,9 @@ def _dump_snapshot_bundle( "tileset": (TILESET_SCHEMA, tileset_document), "region": (REGION_SCHEMA, region_document), } + loaded_documents = { + name: document for name, (_, document) in documents.items() + } references: dict[str, object] = {} for name, (schema, document) in documents.items(): encoded = _encode_document(document) @@ -1281,6 +1295,7 @@ def _dump_snapshot_bundle( "sha256": digest, "schema": REDUCTION_SCHEMA, } + loaded_documents["reduction"] = explanation_document manifest_schema = ( REDUCTION_MANIFEST_SCHEMA if explanation is not None else MANIFEST_SCHEMA @@ -1296,6 +1311,16 @@ def _dump_snapshot_bundle( validate_explain_manifest(manifest) else: validate_reduction_explain_manifest(manifest) + validated_source_digest = _validate_base_bundle_identity( + manifest, + loaded_documents, + ) + if explanation is not None: + _validate_reduction_bundle_identity( + manifest, + loaded_documents, + validated_source_digest, + ) _write_atomic(destination, _encode_document(manifest)) if explanation is None: load_explainability_bundle(destination) diff --git a/python/native/region_adapter.py b/python/native/region_adapter.py index e10470d..57a5383 100644 --- a/python/native/region_adapter.py +++ b/python/native/region_adapter.py @@ -92,6 +92,12 @@ class _YangZhangReduction(Structure): ("region", _Region), ("swaps", POINTER(_AdjacentSwap)), ("swap_count", c_size_t), + ] + + +class _YangZhangExplainedReduction(Structure): + _fields_ = [ + ("reduction", _YangZhangReduction), ("explanation", _ReductionExplanation), ] @@ -110,13 +116,17 @@ def _region_library() -> CDLL: lib.yang_zhang_build.restype = c_bool lib.yang_zhang_build_explained.argtypes = [ POINTER(_Cm13Formula), - POINTER(_YangZhangReduction), + POINTER(_YangZhangExplainedReduction), ] lib.yang_zhang_build_explained.restype = c_bool lib.yang_zhang_reduction_destroy.argtypes = [ POINTER(_YangZhangReduction) ] lib.yang_zhang_reduction_destroy.restype = None + lib.yang_zhang_explained_reduction_destroy.argtypes = [ + POINTER(_YangZhangExplainedReduction) + ] + lib.yang_zhang_explained_reduction_destroy.restype = None return lib @@ -206,7 +216,7 @@ def _copy_gadget(native_gadget: _ReductionGadgetSpan) -> ReductionGadget: def _copy_reduction_explanation( - native_reduction: _YangZhangReduction, + native_reduction: _YangZhangExplainedReduction, variable_count: int, ) -> ReductionExplanation: native = native_reduction.explanation @@ -230,8 +240,8 @@ def _copy_reduction_explanation( ) return ReductionExplanation( variable_count=variable_count, - width=int(native_reduction.region.width), - height=int(native_reduction.region.height), + width=int(native_reduction.reduction.region.width), + height=int(native_reduction.reduction.region.height), source_signals=source, target_signals=target, gadgets=gadgets, @@ -241,18 +251,11 @@ def _copy_reduction_explanation( @contextmanager def _built_reduction( native_formula: _Cm13Formula, - *, - explained: bool = False, ) -> Iterator[_YangZhangReduction]: native_reduction = _YangZhangReduction() lib = _region_library() try: - build = ( - lib.yang_zhang_build_explained - if explained - else lib.yang_zhang_build - ) - if not build( + if not lib.yang_zhang_build( byref(native_formula), byref(native_reduction), ): @@ -262,6 +265,25 @@ def _built_reduction( lib.yang_zhang_reduction_destroy(byref(native_reduction)) +@contextmanager +def _built_explained_reduction( + native_formula: _Cm13Formula, +) -> Iterator[_YangZhangExplainedReduction]: + native_reduction = _YangZhangExplainedReduction() + lib = _region_library() + try: + if not lib.yang_zhang_build_explained( + byref(native_formula), + byref(native_reduction), + ): + raise RegionBuildError( + "could not build explained Yang-Zhang region" + ) + yield native_reduction + finally: + lib.yang_zhang_explained_reduction_destroy(byref(native_reduction)) + + def _build_region(native_formula: _Cm13Formula) -> Region: with _built_reduction(native_formula) as native_reduction: return _copy_region(native_reduction.region) @@ -270,9 +292,9 @@ def _build_region(native_formula: _Cm13Formula) -> Region: def _build_region_and_explanation( native_formula: _Cm13Formula, ) -> tuple[Region, ReductionExplanation]: - with _built_reduction(native_formula, explained=True) as native_reduction: + with _built_explained_reduction(native_formula) as native_reduction: return ( - _copy_region(native_reduction.region), + _copy_region(native_reduction.reduction.region), _copy_reduction_explanation( native_reduction, int(native_formula.variable_count), diff --git a/src/builder/yang_zhang.c b/src/builder/yang_zhang.c index 72a02ce..e4b87e8 100644 --- a/src/builder/yang_zhang.c +++ b/src/builder/yang_zhang.c @@ -10,12 +10,19 @@ static bool reduction_is_destroyed(const YangZhangReduction *reduction) && reduction->region.cell_count == 0 && reduction->region.cells == NULL && reduction->swaps == NULL - && reduction->swap_count == 0 - && reduction->explanation.source_signals == NULL - && reduction->explanation.target_signals == NULL - && reduction->explanation.signal_count == 0 - && reduction->explanation.gadgets == NULL - && reduction->explanation.gadget_count == 0; + && reduction->swap_count == 0; +} + +static bool explanation_is_destroyed( + const ReductionExplanation *explanation +) +{ + return explanation != NULL + && explanation->source_signals == NULL + && explanation->target_signals == NULL + && explanation->signal_count == 0 + && explanation->gadgets == NULL + && explanation->gadget_count == 0; } static bool build_reduction_explanation( @@ -442,7 +449,7 @@ static bool paint_crossover_boundaries( static bool yang_zhang_build_internal( const Cm13Formula *formula, YangZhangReduction *out_reduction, - bool include_explanation + ReductionExplanation *out_explanation ) { SignalToken *source = NULL; @@ -456,6 +463,8 @@ static bool yang_zhang_build_internal( ReductionExplanation explanation = {0}; if (!reduction_is_destroyed(out_reduction) || + (out_explanation != NULL && + !explanation_is_destroyed(out_explanation)) || !formula_is_in_reduction_domain(formula)) { return false; } @@ -478,6 +487,13 @@ static bool yang_zhang_build_internal( return false; } + if (out_explanation == NULL) { + free(target); + target = NULL; + free(source); + source = NULL; + } + if (!yang_zhang_compute_dimensions( formula->variable_count, swaps, @@ -497,7 +513,7 @@ static bool yang_zhang_build_internal( swaps, swap_count ) || - (include_explanation && !build_reduction_explanation( + (out_explanation != NULL && !build_reduction_explanation( formula, source, target, @@ -518,10 +534,8 @@ static bool yang_zhang_build_internal( out_reduction->region = region; out_reduction->swaps = swaps; out_reduction->swap_count = swap_count; - out_reduction->explanation = explanation; - if (!include_explanation) { - free(target); - free(source); + if (out_explanation != NULL) { + *out_explanation = explanation; } return true; } @@ -531,15 +545,22 @@ bool yang_zhang_build( YangZhangReduction *out_reduction ) { - return yang_zhang_build_internal(formula, out_reduction, false); + return yang_zhang_build_internal(formula, out_reduction, NULL); } bool yang_zhang_build_explained( const Cm13Formula *formula, - YangZhangReduction *out_reduction + YangZhangExplainedReduction *out_reduction ) { - return yang_zhang_build_internal(formula, out_reduction, true); + if (out_reduction == NULL) { + return false; + } + return yang_zhang_build_internal( + formula, + &out_reduction->reduction, + &out_reduction->explanation + ); } void yang_zhang_reduction_destroy(YangZhangReduction *reduction) @@ -552,6 +573,17 @@ void yang_zhang_reduction_destroy(YangZhangReduction *reduction) free(reduction->swaps); reduction->swaps = NULL; reduction->swap_count = 0; +} + +void yang_zhang_explained_reduction_destroy( + YangZhangExplainedReduction *reduction +) +{ + if (reduction == NULL) { + return; + } + + yang_zhang_reduction_destroy(&reduction->reduction); free(reduction->explanation.gadgets); free(reduction->explanation.target_signals); free(reduction->explanation.source_signals); diff --git a/tests/c/test_yang_zhang.c b/tests/c/test_yang_zhang.c index 782b4a0..5871bf8 100644 --- a/tests/c/test_yang_zhang.c +++ b/tests/c/test_yang_zhang.c @@ -8,6 +8,24 @@ #define ARRAY_COUNT(array) (sizeof(array) / sizeof((array)[0])) +typedef struct { + Region region; + AdjacentSwap *swaps; + size_t swap_count; +} LegacyYangZhangReduction; + +_Static_assert(sizeof(YangZhangReduction) == sizeof(LegacyYangZhangReduction), + "YangZhangReduction ABI size changed"); +_Static_assert(offsetof(YangZhangReduction, region) == + offsetof(LegacyYangZhangReduction, region), + "YangZhangReduction.region ABI offset changed"); +_Static_assert(offsetof(YangZhangReduction, swaps) == + offsetof(LegacyYangZhangReduction, swaps), + "YangZhangReduction.swaps ABI offset changed"); +_Static_assert(offsetof(YangZhangReduction, swap_count) == + offsetof(LegacyYangZhangReduction, swap_count), + "YangZhangReduction.swap_count ABI offset changed"); + static void test_reduction_destroy_accepts_null_and_empty(void) { YangZhangReduction reduction = {0}; @@ -21,11 +39,6 @@ static void test_reduction_destroy_accepts_null_and_empty(void) assert(reduction.region.cells == NULL); assert(reduction.swaps == NULL); assert(reduction.swap_count == 0); - assert(reduction.explanation.source_signals == NULL); - assert(reduction.explanation.target_signals == NULL); - assert(reduction.explanation.signal_count == 0); - assert(reduction.explanation.gadgets == NULL); - assert(reduction.explanation.gadget_count == 0); } static void test_reduction_destroy_releases_and_resets_owned_storage(void) @@ -37,21 +50,6 @@ static void test_reduction_destroy_releases_and_resets_owned_storage(void) reduction.swaps = malloc(2 * sizeof(*reduction.swaps)); assert(reduction.swaps != NULL); reduction.swap_count = 2; - reduction.explanation.source_signals = malloc( - 3 * sizeof(*reduction.explanation.source_signals) - ); - reduction.explanation.target_signals = malloc( - 3 * sizeof(*reduction.explanation.target_signals) - ); - reduction.explanation.gadgets = malloc( - 4 * sizeof(*reduction.explanation.gadgets) - ); - assert(reduction.explanation.source_signals != NULL); - assert(reduction.explanation.target_signals != NULL); - assert(reduction.explanation.gadgets != NULL); - reduction.explanation.signal_count = 3; - reduction.explanation.gadget_count = 4; - yang_zhang_reduction_destroy(&reduction); assert(reduction.region.width == 0); @@ -60,16 +58,49 @@ static void test_reduction_destroy_releases_and_resets_owned_storage(void) assert(reduction.region.cells == NULL); assert(reduction.swaps == NULL); assert(reduction.swap_count == 0); - assert(reduction.explanation.source_signals == NULL); - assert(reduction.explanation.target_signals == NULL); - assert(reduction.explanation.signal_count == 0); - assert(reduction.explanation.gadgets == NULL); - assert(reduction.explanation.gadget_count == 0); - /* A destroyed reduction can be destroyed repeatedly. */ yang_zhang_reduction_destroy(&reduction); } +static void test_explained_reduction_destroy_releases_all_storage(void) +{ + YangZhangExplainedReduction explained = {0}; + + assert(region_init(&explained.reduction.region, 2, 3)); + explained.reduction.swaps = malloc( + 2 * sizeof(*explained.reduction.swaps) + ); + explained.explanation.source_signals = malloc( + 3 * sizeof(*explained.explanation.source_signals) + ); + explained.explanation.target_signals = malloc( + 3 * sizeof(*explained.explanation.target_signals) + ); + explained.explanation.gadgets = malloc( + 4 * sizeof(*explained.explanation.gadgets) + ); + assert(explained.reduction.swaps != NULL); + assert(explained.explanation.source_signals != NULL); + assert(explained.explanation.target_signals != NULL); + assert(explained.explanation.gadgets != NULL); + explained.reduction.swap_count = 2; + explained.explanation.signal_count = 3; + explained.explanation.gadget_count = 4; + + yang_zhang_explained_reduction_destroy(&explained); + + assert(explained.reduction.region.cells == NULL); + assert(explained.reduction.swaps == NULL); + assert(explained.reduction.swap_count == 0); + assert(explained.explanation.source_signals == NULL); + assert(explained.explanation.target_signals == NULL); + assert(explained.explanation.signal_count == 0); + assert(explained.explanation.gadgets == NULL); + assert(explained.explanation.gadget_count == 0); + yang_zhang_explained_reduction_destroy(&explained); + yang_zhang_explained_reduction_destroy(NULL); +} + static Cm13Formula one_variable_formula(Cm13Clause clauses[1]) { clauses[0] = (Cm13Clause){ .variable_index = { 0, 0, 0 } }; @@ -89,6 +120,13 @@ static void assert_reduction_destroyed(const YangZhangReduction *reduction) assert(reduction->region.cells == NULL); assert(reduction->swaps == NULL); assert(reduction->swap_count == 0); +} + +static void assert_explained_reduction_destroyed( + const YangZhangExplainedReduction *reduction +) +{ + assert_reduction_destroyed(&reduction->reduction); assert(reduction->explanation.source_signals == NULL); assert(reduction->explanation.target_signals == NULL); assert(reduction->explanation.signal_count == 0); @@ -343,20 +381,33 @@ static void test_build_rejects_non_destroyed_output(void) assert(reduction.region.width == 1); } +static void test_explained_build_rejects_non_destroyed_output(void) +{ + Cm13Clause clauses[1]; + Cm13Formula formula = one_variable_formula(clauses); + YangZhangExplainedReduction reduction = { + .explanation = { .signal_count = 1 } + }; + + assert(!yang_zhang_build_explained(&formula, &reduction)); + assert_reduction_destroyed(&reduction.reduction); + assert(reduction.explanation.signal_count == 1); +} + static void test_build_minimal_valid_formula_with_explanation(void) { Cm13Clause clauses[1]; Cm13Formula formula = one_variable_formula(clauses); const Cm13Clause clauses_before[1] = { clauses[0] }; - YangZhangReduction reduction = {0}; + YangZhangExplainedReduction reduction = {0}; const bool top_is_r[7] = {false}; const bool bottom_is_l[7] = {false}; assert(yang_zhang_build_explained(&formula, &reduction)); - assert(reduction.region.width == 7); - assert(reduction.region.height == 3); - assert(reduction.swaps == NULL); - assert(reduction.swap_count == 0); + assert(reduction.reduction.region.width == 7); + assert(reduction.reduction.region.height == 3); + assert(reduction.reduction.swaps == NULL); + assert(reduction.reduction.swap_count == 0); assert(reduction.explanation.signal_count == 3); assert(reduction.explanation.source_signals != NULL); assert(reduction.explanation.target_signals != NULL); @@ -412,14 +463,14 @@ static void test_build_minimal_valid_formula_with_explanation(void) 3, REDUCTION_NO_SWAP_ROW ); - assert_region_encoding(&reduction.region, top_is_r, bottom_is_l); + assert_region_encoding(&reduction.reduction.region, top_is_r, bottom_is_l); assert(memcmp(clauses, clauses_before, sizeof(clauses)) == 0); - yang_zhang_reduction_destroy(&reduction); - assert_reduction_destroyed(&reduction); + yang_zhang_explained_reduction_destroy(&reduction); + assert_explained_reduction_destroyed(&reduction); } -static void test_standard_build_omits_explanation(void) +static void test_standard_build_preserves_compact_result(void) { Cm13Clause clauses[1]; Cm13Formula formula = one_variable_formula(clauses); @@ -428,12 +479,6 @@ static void test_standard_build_omits_explanation(void) assert(yang_zhang_build(&formula, &reduction)); assert(reduction.region.width == 7); assert(reduction.region.height == 3); - assert(reduction.explanation.source_signals == NULL); - assert(reduction.explanation.target_signals == NULL); - assert(reduction.explanation.signal_count == 0); - assert(reduction.explanation.gadgets == NULL); - assert(reduction.explanation.gadget_count == 0); - yang_zhang_reduction_destroy(&reduction); assert_reduction_destroyed(&reduction); } @@ -512,14 +557,14 @@ static void test_build_paper_example(void) redundant_token(3, 1), variable_token(0, 2), variable_token(1, 2), variable_token(2, 2) }; - YangZhangReduction reduction = {0}; + YangZhangExplainedReduction reduction = {0}; bool top_is_r[96] = {false}; bool bottom_is_l[96] = {false}; assert(yang_zhang_build_explained(&formula, &reduction)); - assert(reduction.region.height == 11); - assert(reduction.region.width == 96); - assert(reduction.swap_count == ARRAY_COUNT(expected_rows)); + assert(reduction.reduction.region.height == 11); + assert(reduction.reduction.region.width == 96); + assert(reduction.reduction.swap_count == ARRAY_COUNT(expected_rows)); assert(reduction.explanation.signal_count == ARRAY_COUNT(source)); assert(reduction.explanation.gadget_count == 2u * formula.variable_count + 2u + ARRAY_COUNT(expected_rows)); @@ -561,11 +606,11 @@ static void test_build_paper_example(void) int32_t block_x = (int32_t)(YANG_ZHANG_VARIABLE_WIDTH + YANG_ZHANG_LEFT_FORWARD_WIDTH); size_t crossover_width = 0; - for (size_t i = 0; i < reduction.swap_count; ++i) { + for (size_t i = 0; i < reduction.reduction.swap_count; ++i) { const uint32_t row = expected_rows[i]; const int32_t block_width = (int32_t)row + 1; - assert(reduction.swaps[i].row == row); + assert(reduction.reduction.swaps[i].row == row); assert_gadget_span( &reduction.explanation.gadgets[formula.variable_count + 1u + i], REDUCTION_GADGET_CROSSOVER, @@ -600,7 +645,7 @@ static void test_build_paper_example(void) const int32_t first_y = (int32_t)(4u * clause); const int32_t y_end = clause + 1u < formula.clause_count ? first_y + 4 - : reduction.region.height; + : reduction.reduction.region.height; assert_gadget_span( &reduction.explanation.gadgets[right_index + 1u + clause], REDUCTION_GADGET_CLAUSE, @@ -616,18 +661,18 @@ static void test_build_paper_example(void) assert(yang_zhang_permutation_apply( source, ARRAY_COUNT(source), - reduction.swaps, - reduction.swap_count + reduction.reduction.swaps, + reduction.reduction.swap_count )); for (size_t i = 0; i < ARRAY_COUNT(source); ++i) { assert(tokens_equal(&source[i], &target[i])); } - assert_region_encoding(&reduction.region, top_is_r, bottom_is_l); + assert_region_encoding(&reduction.reduction.region, top_is_r, bottom_is_l); assert(memcmp(clauses, clauses_before, sizeof(clauses)) == 0); - yang_zhang_reduction_destroy(&reduction); - assert_reduction_destroyed(&reduction); + yang_zhang_explained_reduction_destroy(&reduction); + assert_explained_reduction_destroyed(&reduction); } static void test_paper_dimensions_from_known_swaps(void) @@ -913,13 +958,15 @@ int main(void) { test_reduction_destroy_accepts_null_and_empty(); test_reduction_destroy_releases_and_resets_owned_storage(); + test_explained_reduction_destroy_releases_all_storage(); test_build_rejects_null_arguments(); test_build_rejects_invalid_formula_domain(); test_build_rejects_variable_count_overflow(); test_failed_build_does_not_modify_formula_storage(); test_build_rejects_non_destroyed_output(); + test_explained_build_rejects_non_destroyed_output(); test_build_minimal_valid_formula_with_explanation(); - test_standard_build_omits_explanation(); + test_standard_build_preserves_compact_result(); test_dimensions_normal(); test_build_paper_example(); diff --git a/tests/python/test_pipeline_snapshot.py b/tests/python/test_pipeline_snapshot.py index 8c8fe98..e626b76 100644 --- a/tests/python/test_pipeline_snapshot.py +++ b/tests/python/test_pipeline_snapshot.py @@ -31,6 +31,7 @@ validate_region_snapshot, validate_tileset_snapshot, ) +from model.formula import Formula from model.tileset import TILESET from native.reduction_adapter import load_formula_region_and_explanation @@ -186,6 +187,14 @@ def test_reduction_snapshot_records_native_signals_and_gadgets(self) -> None: with self.assertRaisesRegex(PipelineSnapshotError, "must be an integer"): validate_reduction_explanation_snapshot(bool_ordinal) + duplicate_occurrence = copy.deepcopy(document) + duplicate_occurrence["signals"]["source"][1]["occurrence"] = 0 + with self.assertRaisesRegex( + PipelineSnapshotError, + "must contain occurrences 0, 1, and 2", + ): + validate_reduction_explanation_snapshot(duplicate_occurrence) + def test_v2_bundle_is_deterministic_and_cross_stage_hash_bound(self) -> None: snapshots: list[dict[str, bytes]] = [] for _ in range(2): @@ -250,6 +259,37 @@ def test_v2_bundle_rejects_rebound_reduction_with_wrong_region(self) -> None: ): load_reduction_explainability_bundle(manifest_path) + def test_v2_cross_validation_failure_preserves_existing_manifest(self) -> None: + mismatched_formula = Formula( + variable_count=self.formula.variable_count, + clauses=tuple(reversed(self.formula.clauses)), + ) + with tempfile.TemporaryDirectory() as directory: + manifest_path = Path(directory) / "pipeline.explain.json" + dump_reduction_explanation_snapshots( + manifest_path, + SAT_PATH, + self.formula, + self.region, + self.explanation, + ) + original_manifest = manifest_path.read_bytes() + + with self.assertRaisesRegex( + PipelineSnapshotError, + "does not match the formula clauses", + ): + dump_reduction_explanation_snapshots( + manifest_path, + SAT_PATH, + mismatched_formula, + self.region, + self.explanation, + ) + + self.assertEqual(manifest_path.read_bytes(), original_manifest) + load_reduction_explainability_bundle(manifest_path) + def test_manifest_rejects_changed_artifact_bytes(self) -> None: with tempfile.TemporaryDirectory() as directory: manifest_path = dump_pipeline_snapshots(