From 7dcc49706fd9fb32b28ded10caf4cec9f660626c Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Tue, 25 Aug 2026 02:50:39 +0200 Subject: [PATCH] Define the versioned square Wang solution contract --- docs/wang_solution_v1.md | 122 ++++++ python/formats/wang_solution.py | 347 ++++++++++++++++++ schemas/.gitkeep | 0 schemas/tiling.schema.json | 0 schemas/wang-solution-v1.schema.json | 178 +++++++++ .../fixtures/wang_solution_v1_square_sat.json | 128 +++++++ tests/python/test_wang_solution_format.py | 208 +++++++++++ 7 files changed, 983 insertions(+) create mode 100644 docs/wang_solution_v1.md create mode 100644 python/formats/wang_solution.py delete mode 100644 schemas/.gitkeep delete mode 100644 schemas/tiling.schema.json create mode 100644 schemas/wang-solution-v1.schema.json create mode 100644 tests/fixtures/wang_solution_v1_square_sat.json create mode 100644 tests/python/test_wang_solution_format.py diff --git a/docs/wang_solution_v1.md b/docs/wang_solution_v1.md new file mode 100644 index 0000000..d270d4d --- /dev/null +++ b/docs/wang_solution_v1.md @@ -0,0 +1,122 @@ +--- +layout: page +title: Wang solution v1 data contract +permalink: /wang-solution-v1/ +description: Versioned JSON contract and independent semantic checks for square Wang SAT witnesses. +section: Architecture and correctness +document_kind: Data contract +status: Current specification +updated: 2026-08-25 +nav_order: 31 +--- + +# Wang solution v1 data contract + +## Scope + +`wang-solution-v1` is the boundary between a producer of a verified square +Wang tiling and downstream tools such as a renderer. It is a SAT-only data +contract: `status` is always `SAT`, `geometry` is always `square`, and every +active position selects one tile. UNSAT and UNKNOWN outcomes are not solution +documents. + +The format contains no native pointers, solver domains, search traces, +Yang–Zhang swap data, renderer assets, or hexagonal coordinates. A consumer +can validate and inspect the fixture without loading the native library. + +The machine-readable structural definition is +`schemas/wang-solution-v1.schema.json`. The dependency-free semantic validator +is `python/formats/wang_solution.py`, and the representative golden document is +`tests/fixtures/wang_solution_v1_square_sat.json`. + +## Fields and ordering + +The top-level fields are closed and have these meanings: + +| Field | Contract | +| --- | --- | +| `schema` | Literal `wang-solution-v1`. | +| `status` | Literal `SAT`; non-SAT results have no v1 solution document. | +| `geometry` | Literal `square`. | +| `bounds` | Inclusive `min_x_inclusive`, `min_y_inclusive`, `max_x_inclusive`, and `max_y_inclusive` coordinates. | +| `tile_table` | Canonical positional table from `tile_id` to the integer colors `N`, `E`, `S`, and `W`. | +| `cells` | Dense row-major tile IDs; JSON `null` marks a hole. | +| `boundary` | Dense row-major boundary constraints; a hole has a null entry, while an active cell has nullable `N`, `E`, `S`, and `W` colors. | +| `metadata` | Arbitrary JSON object that is explicitly non-semantic. | + +The inclusive width and height are: + +```text +width = max_x_inclusive - min_x_inclusive + 1 +height = max_y_inclusive - min_y_inclusive + 1 +``` + +Both dense arrays therefore have `width * height` entries. Array index `i` +maps to local `(i % width, i // width)` and absolute coordinates +`(min_x_inclusive + i % width, min_y_inclusive + i // width)`. Coordinate +offsets do not change adjacency. + +`tile_table[i].tile_id` must equal `i`, so IDs are unique, consecutive, and +canonical. Every active `cells` entry indexes that table. Edge order is always +`(N,E,S,W)` even though JSON stores the four names explicitly. + +An active boundary entry always has all four direction names. A null color +means no boundary constraint. Constraints are legal only on an exposed side: +the neighboring coordinate lies outside the inclusive bounds or is a hole. +The color must equal the selected tile edge. Boundary constraints do not +replace ordinary east/west and south/north matching between active cells. + +## Structural and semantic guarantees + +The JSON Schema establishes only properties local to one JSON value: + +- exact top-level and nested member names; +- literal schema, status, and geometry values; +- object and array shapes; +- integer, null, and nonnegative-color types; +- the presence of all four named directions; +- an object, but no correctness meaning, for `metadata`. + +JSON Schema alone does not establish relationships between separate fields. +The standard-library validator first mirrors the structural checks and then +establishes the cross-field semantics: + +- inclusive minima do not exceed inclusive maxima; +- both dense arrays have exactly the bounds area; +- tile IDs equal their canonical positions and all cell references exist; +- holes and active cells have the corresponding boundary representation; +- constraints occur only on exposed edges and match selected tile colors; +- every active horizontal and vertical adjacency has equal colors. + +Passing only the structural schema is not a correctness claim. +`validate_wang_solution()` is transport and contract validation: it establishes +the internal consistency of the serialized witness, but it is neither a solver +nor the independent application verifier. The integration boundary must run +the independent verifier before presenting a solution as correct. + +The renderer remains a presentation-only consumer. It must not import this +formats module, replace the independent verifier, or decide correctness from a +successful render. `load_wang_solution()` additionally rejects malformed JSON, +duplicate object members, and non-finite numeric extensions accepted by some +JSON parsers. + +## Metadata boundary + +`metadata` may carry producer names, display labels, timestamps, or similar +diagnostics. Its contents must never select a tile, change coordinates, +override a boundary color, affect SAT status, or be required to reproduce the +tiling. Semantic validation deliberately ignores the entire object after +checking that it contains JSON values. + +## Versioning + +V1 is closed: unknown structural fields are rejected. A breaking change to +geometry, coordinate meaning, tile identity, dense ordering, boundary +semantics, or correctness rules requires a new schema name and a separate +validator. Non-semantic producer or presentation data belongs in `metadata`; +placing it there does not expand the correctness contract. + +The golden fixture spans inclusive bounds `[-1,2]` through `[2,4]`: twelve +dense positions, ten active cells, and two holes. Its complete 23-entry table +is tested for exact `(N,E,S,W)` parity with the canonical Python `TILESET`, and +targeted mutations exercise every cross-field rejection above. diff --git a/python/formats/wang_solution.py b/python/formats/wang_solution.py new file mode 100644 index 0000000..c31e2e9 --- /dev/null +++ b/python/formats/wang_solution.py @@ -0,0 +1,347 @@ +"""Standard-library validation for the ``wang-solution-v1`` contract.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +from typing import Final + + +SCHEMA_NAME: Final = "wang-solution-v1" +GEOMETRY: Final = "square" +STATUS: Final = "SAT" +_DIRECTIONS: Final = ("N", "E", "S", "W") +_OFFSETS: Final = ((0, -1), (1, 0), (0, 1), (-1, 0)) +_OPPOSITE: Final = (2, 3, 0, 1) +_TOP_LEVEL_FIELDS: Final = frozenset( + { + "schema", + "status", + "geometry", + "bounds", + "tile_table", + "cells", + "boundary", + "metadata", + } +) +_BOUND_FIELDS: Final = frozenset( + { + "min_x_inclusive", + "min_y_inclusive", + "max_x_inclusive", + "max_y_inclusive", + } +) + + +class WangSolutionValidationError(ValueError): + """Raised when a document violates the v1 structure or semantics.""" + + +def _fail(path: str, message: str) -> None: + raise WangSolutionValidationError(f"{path}: {message}") + + +def _require_object(value: object, path: str) -> dict[str, object]: + if type(value) is not dict: + _fail(path, "must be an object") + return value + + +def _require_array(value: object, path: str) -> list[object]: + if type(value) is not list: + _fail(path, "must be an array") + return value + + +def _require_integer(value: object, path: str, *, nonnegative: bool) -> int: + if type(value) is not int: + _fail(path, "must be an integer") + if nonnegative and value < 0: + _fail(path, "must be nonnegative") + return value + + +def _require_exact_fields( + value: dict[str, object], + expected: frozenset[str], + path: str, +) -> None: + if any(type(key) is not str for key in value): + _fail(path, "object member names must be strings") + actual = frozenset(value) + missing = sorted(expected - actual) + extra = sorted(actual - expected) + if missing: + _fail(path, f"missing fields: {', '.join(missing)}") + if extra: + _fail(path, f"unknown fields: {', '.join(extra)}") + + +def _validate_json_value(value: object, path: str) -> None: + if value is None or type(value) in (str, bool, int): + return + if type(value) is float: + if not math.isfinite(value): + _fail(path, "must not contain a non-finite number") + return + if type(value) is list: + for index, item in enumerate(value): + _validate_json_value(item, f"{path}[{index}]") + return + if type(value) is dict: + for key, item in value.items(): + if type(key) is not str: + _fail(path, "object member names must be strings") + _validate_json_value(item, f"{path}.{key}") + return + _fail(path, "must contain only JSON values") + + +def _validate_edges( + value: object, + path: str, + *, + optional: bool, +) -> None: + edges = _require_object(value, path) + _require_exact_fields(edges, frozenset(_DIRECTIONS), path) + for direction in _DIRECTIONS: + color = edges[direction] + if optional and color is None: + continue + _require_integer(color, f"{path}.{direction}", nonnegative=True) + + +def validate_wang_solution_structure(document: object) -> None: + """Validate the constraints expressible by the published JSON Schema. + + This stdlib mirror lets consumers reject malformed input without adding a + JSON Schema implementation. It intentionally does not establish that the + arrays, identifiers, boundaries, and adjacent tiles describe one tiling. + """ + root = _require_object(document, "$") + _require_exact_fields(root, _TOP_LEVEL_FIELDS, "$") + + for field, expected in ( + ("schema", SCHEMA_NAME), + ("status", STATUS), + ("geometry", GEOMETRY), + ): + if root[field] != expected or type(root[field]) is not str: + _fail(f"$.{field}", f"must equal {expected!r}") + + bounds = _require_object(root["bounds"], "$.bounds") + _require_exact_fields(bounds, _BOUND_FIELDS, "$.bounds") + for field in sorted(_BOUND_FIELDS): + _require_integer(bounds[field], f"$.bounds.{field}", nonnegative=False) + + tile_table = _require_array(root["tile_table"], "$.tile_table") + if not tile_table: + _fail("$.tile_table", "must not be empty") + for index, raw_tile in enumerate(tile_table): + path = f"$.tile_table[{index}]" + tile = _require_object(raw_tile, path) + _require_exact_fields(tile, frozenset({"tile_id", "edges"}), path) + _require_integer(tile["tile_id"], f"{path}.tile_id", nonnegative=True) + _validate_edges(tile["edges"], f"{path}.edges", optional=False) + + cells = _require_array(root["cells"], "$.cells") + if not cells: + _fail("$.cells", "must not be empty") + for index, tile_id in enumerate(cells): + if tile_id is not None: + _require_integer(tile_id, f"$.cells[{index}]", nonnegative=True) + + boundary = _require_array(root["boundary"], "$.boundary") + if not boundary: + _fail("$.boundary", "must not be empty") + for index, sides in enumerate(boundary): + if sides is not None: + _validate_edges(sides, f"$.boundary[{index}]", optional=True) + + metadata = _require_object(root["metadata"], "$.metadata") + _validate_json_value(metadata, "$.metadata") + + +def validate_wang_solution(document: object) -> None: + """Validate v1 structure and all square-tiling cross-field semantics.""" + validate_wang_solution_structure(document) + root = _require_object(document, "$") + bounds = _require_object(root["bounds"], "$.bounds") + + min_x = _require_integer( + bounds["min_x_inclusive"], + "$.bounds.min_x_inclusive", + nonnegative=False, + ) + min_y = _require_integer( + bounds["min_y_inclusive"], + "$.bounds.min_y_inclusive", + nonnegative=False, + ) + max_x = _require_integer( + bounds["max_x_inclusive"], + "$.bounds.max_x_inclusive", + nonnegative=False, + ) + max_y = _require_integer( + bounds["max_y_inclusive"], + "$.bounds.max_y_inclusive", + nonnegative=False, + ) + if max_x < min_x: + _fail("$.bounds", "max_x_inclusive must be at least min_x_inclusive") + if max_y < min_y: + _fail("$.bounds", "max_y_inclusive must be at least min_y_inclusive") + + width = max_x - min_x + 1 + height = max_y - min_y + 1 + area = width * height + cells = _require_array(root["cells"], "$.cells") + boundary = _require_array(root["boundary"], "$.boundary") + if len(cells) != area: + _fail("$.cells", f"length must equal inclusive bounds area {area}") + if len(boundary) != area: + _fail("$.boundary", f"length must equal inclusive bounds area {area}") + + raw_table = _require_array(root["tile_table"], "$.tile_table") + tiles: list[tuple[int, int, int, int]] = [] + for expected_id, raw_tile in enumerate(raw_table): + tile = _require_object(raw_tile, f"$.tile_table[{expected_id}]") + tile_id = _require_integer( + tile["tile_id"], + f"$.tile_table[{expected_id}].tile_id", + nonnegative=True, + ) + if tile_id != expected_id: + _fail( + f"$.tile_table[{expected_id}].tile_id", + f"must equal its canonical table position {expected_id}", + ) + edges = _require_object( + tile["edges"], f"$.tile_table[{expected_id}].edges" + ) + tiles.append( + tuple( + _require_integer( + edges[direction], + f"$.tile_table[{expected_id}].edges.{direction}", + nonnegative=True, + ) + for direction in _DIRECTIONS + ) + ) + + for index, tile_id in enumerate(cells): + sides = boundary[index] + if tile_id is None: + if sides is not None: + _fail( + f"$.boundary[{index}]", + "must be null because the corresponding cell is a hole", + ) + continue + assert type(tile_id) is int + if tile_id >= len(tiles): + _fail( + f"$.cells[{index}]", + f"references absent tile_id {tile_id}", + ) + if sides is None: + _fail( + f"$.boundary[{index}]", + "must contain N/E/S/W entries for an active cell", + ) + assert type(sides) is dict + + x = index % width + y = index // width + tile = tiles[tile_id] + for direction_index, (direction, (dx, dy)) in enumerate( + zip(_DIRECTIONS, _OFFSETS, strict=True) + ): + required_color = sides[direction] + neighbor_x = x + dx + neighbor_y = y + dy + neighbor_is_active = False + if 0 <= neighbor_x < width and 0 <= neighbor_y < height: + neighbor_index = neighbor_y * width + neighbor_x + neighbor_is_active = cells[neighbor_index] is not None + + if neighbor_is_active and required_color is not None: + _fail( + f"$.boundary[{index}].{direction}", + "must be null on an edge shared by active cells", + ) + if required_color is not None and required_color != tile[direction_index]: + _fail( + f"$.boundary[{index}].{direction}", + "does not match the selected tile edge color", + ) + + for y in range(height): + for x in range(width): + index = y * width + x + tile_id = cells[index] + if tile_id is None: + continue + assert type(tile_id) is int + tile = tiles[tile_id] + for direction_index in (1, 2): + dx, dy = _OFFSETS[direction_index] + neighbor_x = x + dx + neighbor_y = y + dy + if neighbor_x >= width or neighbor_y >= height: + continue + neighbor_index = neighbor_y * width + neighbor_x + neighbor_id = cells[neighbor_index] + if neighbor_id is None: + continue + assert type(neighbor_id) is int + if tile[direction_index] != tiles[neighbor_id][ + _OPPOSITE[direction_index] + ]: + direction = _DIRECTIONS[direction_index] + _fail( + f"$.cells[{index}]/{direction}/$.cells[{neighbor_index}]", + "adjacent tile edge colors do not match", + ) + + +def _reject_duplicate_members(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise WangSolutionValidationError( + f"JSON object contains duplicate member {key!r}" + ) + result[key] = value + return result + + +def _reject_nonfinite_constant(value: str) -> None: + raise WangSolutionValidationError( + f"JSON document contains non-finite number {value}" + ) + + +def load_wang_solution(path: str | Path) -> dict[str, object]: + """Load strict JSON from *path*, validate it, and return the document.""" + try: + with Path(path).open(encoding="utf-8") as stream: + document = json.load( + stream, + object_pairs_hook=_reject_duplicate_members, + parse_constant=_reject_nonfinite_constant, + ) + except json.JSONDecodeError as error: + raise WangSolutionValidationError( + f"invalid JSON at line {error.lineno}, column {error.colno}: {error.msg}" + ) from error + + validate_wang_solution(document) + assert type(document) is dict + return document diff --git a/schemas/.gitkeep b/schemas/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/schemas/tiling.schema.json b/schemas/tiling.schema.json deleted file mode 100644 index e69de29..0000000 diff --git a/schemas/wang-solution-v1.schema.json b/schemas/wang-solution-v1.schema.json new file mode 100644 index 0000000..45162f2 --- /dev/null +++ b/schemas/wang-solution-v1.schema.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tiling-foundry:schema:wang-solution-v1", + "title": "Tiling Foundry square Wang solution v1", + "description": "Structural contract for a SAT square Wang tiling. Cross-field tiling semantics are validated separately.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "status", + "geometry", + "bounds", + "tile_table", + "cells", + "boundary", + "metadata" + ], + "properties": { + "schema": { + "const": "wang-solution-v1" + }, + "status": { + "const": "SAT" + }, + "geometry": { + "const": "square" + }, + "bounds": { + "$ref": "#/$defs/inclusive_bounds" + }, + "tile_table": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/tile" + } + }, + "cells": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "boundary": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/optional_edges" + }, + { + "type": "null" + } + ] + } + }, + "metadata": { + "description": "Non-semantic producer or display data. Correctness must not depend on this object.", + "type": "object", + "additionalProperties": true + } + }, + "$defs": { + "color": { + "type": "integer", + "minimum": 0 + }, + "inclusive_bounds": { + "type": "object", + "additionalProperties": false, + "required": [ + "min_x_inclusive", + "min_y_inclusive", + "max_x_inclusive", + "max_y_inclusive" + ], + "properties": { + "min_x_inclusive": { + "type": "integer" + }, + "min_y_inclusive": { + "type": "integer" + }, + "max_x_inclusive": { + "type": "integer" + }, + "max_y_inclusive": { + "type": "integer" + } + } + }, + "edges": { + "type": "object", + "additionalProperties": false, + "required": [ + "N", + "E", + "S", + "W" + ], + "properties": { + "N": { + "$ref": "#/$defs/color" + }, + "E": { + "$ref": "#/$defs/color" + }, + "S": { + "$ref": "#/$defs/color" + }, + "W": { + "$ref": "#/$defs/color" + } + } + }, + "optional_color": { + "oneOf": [ + { + "$ref": "#/$defs/color" + }, + { + "type": "null" + } + ] + }, + "optional_edges": { + "type": "object", + "additionalProperties": false, + "required": [ + "N", + "E", + "S", + "W" + ], + "properties": { + "N": { + "$ref": "#/$defs/optional_color" + }, + "E": { + "$ref": "#/$defs/optional_color" + }, + "S": { + "$ref": "#/$defs/optional_color" + }, + "W": { + "$ref": "#/$defs/optional_color" + } + } + }, + "tile": { + "type": "object", + "additionalProperties": false, + "required": [ + "tile_id", + "edges" + ], + "properties": { + "tile_id": { + "type": "integer", + "minimum": 0 + }, + "edges": { + "$ref": "#/$defs/edges" + } + } + } + } +} diff --git a/tests/fixtures/wang_solution_v1_square_sat.json b/tests/fixtures/wang_solution_v1_square_sat.json new file mode 100644 index 0000000..f0e9f28 --- /dev/null +++ b/tests/fixtures/wang_solution_v1_square_sat.json @@ -0,0 +1,128 @@ +{ + "schema": "wang-solution-v1", + "status": "SAT", + "geometry": "square", + "bounds": { + "min_x_inclusive": -1, + "min_y_inclusive": 2, + "max_x_inclusive": 2, + "max_y_inclusive": 4 + }, + "tile_table": [ + { + "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} + } + ], + "cells": [ + 7, 7, null, 7, + 7, 7, null, 7, + 7, 7, 7, 7 + ], + "boundary": [ + {"N": 0, "E": null, "S": null, "W": 2}, + {"N": 0, "E": 2, "S": null, "W": null}, + null, + {"N": 0, "E": 2, "S": null, "W": 2}, + {"N": null, "E": null, "S": null, "W": 2}, + {"N": null, "E": 2, "S": null, "W": null}, + null, + {"N": null, "E": 2, "S": null, "W": 2}, + {"N": null, "E": null, "S": 0, "W": 2}, + {"N": null, "E": null, "S": 0, "W": null}, + {"N": 0, "E": null, "S": 0, "W": null}, + {"N": null, "E": 2, "S": 0, "W": null} + ], + "metadata": { + "fixture": "canonical-tileset-square-with-holes", + "note": "This object is not used to establish tiling correctness." + } +} diff --git a/tests/python/test_wang_solution_format.py b/tests/python/test_wang_solution_format.py new file mode 100644 index 0000000..458738a --- /dev/null +++ b/tests/python/test_wang_solution_format.py @@ -0,0 +1,208 @@ +import copy +import json +from pathlib import Path +import tempfile +import unittest + +from formats.wang_solution import ( + WangSolutionValidationError, + load_wang_solution, + validate_wang_solution, + validate_wang_solution_structure, +) +from model.region import Region +from model.tileset import COLOR_NONE, TILESET +from oracles.tiling_check import is_valid_tiling + + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE = ROOT / "tests/fixtures/wang_solution_v1_square_sat.json" +SCHEMA = ROOT / "schemas/wang-solution-v1.schema.json" +DIRECTIONS = ("N", "E", "S", "W") + + +def _fixture() -> dict[str, object]: + with FIXTURE.open(encoding="utf-8") as stream: + value = json.load(stream) + assert type(value) is dict + return value + + +def _boundary_sides(document: dict[str, object], index: int) -> dict[str, object]: + boundary = document["boundary"] + assert type(boundary) is list + sides = boundary[index] + assert type(sides) is dict + return sides + + +class WangSolutionSchemaTests(unittest.TestCase): + def test_publishes_a_closed_draft_2020_12_sat_square_schema(self) -> None: + with SCHEMA.open(encoding="utf-8") as stream: + schema = json.load(stream) + + self.assertEqual( + schema["$schema"], "https://json-schema.org/draft/2020-12/schema" + ) + self.assertFalse(schema["additionalProperties"]) + self.assertEqual(schema["properties"]["schema"]["const"], "wang-solution-v1") + self.assertEqual(schema["properties"]["status"]["const"], "SAT") + self.assertEqual(schema["properties"]["geometry"]["const"], "square") + + def test_stdlib_structure_check_rejects_malformed_documents(self) -> None: + mutations = { + "wrong status": lambda value: value.__setitem__("status", "UNSAT"), + "unknown field": lambda value: value.__setitem__("extra", None), + "boolean tile id": lambda value: value["cells"].__setitem__(0, True), + "boolean bound": lambda value: value["bounds"].__setitem__( + "min_x_inclusive", False + ), + "boolean color": lambda value: value["tile_table"][0][ + "edges" + ].__setitem__("N", True), + "missing edge": lambda value: value["tile_table"][0]["edges"].pop("N"), + "non-object metadata": lambda value: value.__setitem__("metadata", []), + } + + for name, mutate in mutations.items(): + with self.subTest(name=name): + document = _fixture() + mutate(document) + with self.assertRaises(WangSolutionValidationError): + validate_wang_solution_structure(document) + + +class WangSolutionSemanticTests(unittest.TestCase): + def test_loads_the_golden_fixture_and_matches_the_canonical_tileset(self) -> None: + document = load_wang_solution(FIXTURE) + table = document["tile_table"] + assert type(table) is list + serialized_tileset = tuple( + tuple(entry["edges"][direction] for direction in DIRECTIONS) + for entry in table + ) + bounds = document["bounds"] + assert type(bounds) is dict + width = bounds["max_x_inclusive"] - bounds["min_x_inclusive"] + 1 + height = bounds["max_y_inclusive"] - bounds["min_y_inclusive"] + 1 + cells = document["cells"] + boundary = document["boundary"] + assert type(cells) is list + assert type(boundary) is list + region = Region( + width=width, + height=height, + active=tuple(tile_id is not None for tile_id in cells), + boundary=tuple( + (COLOR_NONE,) * 4 + if sides is None + else tuple( + COLOR_NONE if sides[direction] is None else sides[direction] + for direction in DIRECTIONS + ) + for sides in boundary + ), + ) + + self.assertEqual(bounds["min_x_inclusive"], -1) + self.assertEqual((width, height), (4, 3)) + self.assertEqual(len(cells), 12) + self.assertEqual(cells.count(None), 2) + self.assertEqual(serialized_tileset, TILESET) + self.assertTrue( + is_valid_tiling(region, serialized_tileset, tuple(cells)) + ) + + def test_metadata_is_ignored_by_semantic_validation(self) -> None: + document = _fixture() + document["metadata"] = { + "producer": "independent-test", + "nested": [None, True, 1, 1.5, {"label": "display only"}], + } + + validate_wang_solution(document) + + def test_rejects_cross_field_inconsistencies(self) -> None: + def inverted_bounds(value: dict[str, object]) -> None: + value["bounds"]["max_x_inclusive"] = -2 + + def short_cells(value: dict[str, object]) -> None: + value["cells"].pop() + + def short_boundary(value: dict[str, object]) -> None: + value["boundary"].pop() + + def noncanonical_tile_table(value: dict[str, object]) -> None: + value["tile_table"][0], value["tile_table"][1] = ( + value["tile_table"][1], + value["tile_table"][0], + ) + + def absent_tile_reference(value: dict[str, object]) -> None: + value["cells"][0] = len(value["tile_table"]) + + def hole_with_boundary(value: dict[str, object]) -> None: + value["boundary"][2] = {direction: None for direction in DIRECTIONS} + + def active_without_boundary(value: dict[str, object]) -> None: + value["boundary"][0] = None + + def internal_boundary(value: dict[str, object]) -> None: + _boundary_sides(value, 0)["E"] = 2 + + def mismatched_boundary(value: dict[str, object]) -> None: + _boundary_sides(value, 0)["N"] = 1 + + def horizontal_mismatch(value: dict[str, object]) -> None: + value["cells"][1] = 8 + _boundary_sides(value, 1)["E"] = 3 + + def vertical_mismatch(value: dict[str, object]) -> None: + value["cells"][4] = 0 + _boundary_sides(value, 4)["W"] = 1 + + mutations = { + "inverted inclusive bounds": inverted_bounds, + "cell length versus bounds": short_cells, + "boundary length versus bounds": short_boundary, + "noncanonical tile table": noncanonical_tile_table, + "absent tile reference": absent_tile_reference, + "hole carrying boundary": hole_with_boundary, + "active cell missing boundary": active_without_boundary, + "constraint on internal edge": internal_boundary, + "boundary versus selected tile": mismatched_boundary, + "horizontal adjacency": horizontal_mismatch, + "vertical adjacency": vertical_mismatch, + } + + for name, mutate in mutations.items(): + with self.subTest(name=name): + document = _fixture() + mutate(document) + with self.assertRaises(WangSolutionValidationError): + validate_wang_solution(document) + + def test_strict_loader_rejects_duplicate_members_and_nonfinite_numbers(self) -> None: + invalid_documents = ( + '{"schema":"wang-solution-v1","schema":"duplicate"}', + '{"metadata":{"not_json":NaN}}', + ) + for source in invalid_documents: + with self.subTest(source=source): + with tempfile.TemporaryDirectory() as temporary_directory: + path = Path(temporary_directory) / "invalid.json" + path.write_text(source, encoding="utf-8") + with self.assertRaises(WangSolutionValidationError): + load_wang_solution(path) + + def test_mutations_do_not_leak_between_cases(self) -> None: + original = _fixture() + mutated = copy.deepcopy(original) + mutated["metadata"]["label"] = "changed" + + self.assertNotEqual(original, mutated) + validate_wang_solution(original) + + +if __name__ == "__main__": + unittest.main()