From df24fca413751369c957489123048ce944f98c9e Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Sat, 10 Oct 2026 02:40:19 +0200 Subject: [PATCH 1/7] Package reference and optimized C solvers for browser consumers --- .github/workflows/solver-web.yml | 76 ++++++++ Makefile | 4 + README.md | 9 + docs/plans/wang_studio.md | 54 ++++++ include/wang/solver.h | 41 +++++ include/wang/verify.h | 6 + packages/solver-web/README.md | 80 +++++++++ packages/solver-web/bridge.c | 55 ++++++ packages/solver-web/client.mjs | 106 +++++++++++ packages/solver-web/package.json | 8 + packages/solver-web/prepare.mjs | 101 +++++++++++ packages/solver-web/worker.mjs | 85 +++++++++ src/solver/solver_serial.c | 241 +++++++++++++++++++++----- src/verify/verify_tiling.c | 31 +++- tests/c/test_solver_enumeration.c | 63 +++++++ tests/c/test_solver_runtime_tileset.c | 57 ++++++ tests/test_solver_web.mjs | 91 ++++++++++ tools/build_solver_web.py | 86 +++++++++ 18 files changed, 1147 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/solver-web.yml create mode 100644 docs/plans/wang_studio.md create mode 100644 packages/solver-web/README.md create mode 100644 packages/solver-web/bridge.c create mode 100644 packages/solver-web/client.mjs create mode 100644 packages/solver-web/package.json create mode 100644 packages/solver-web/prepare.mjs create mode 100644 packages/solver-web/worker.mjs create mode 100644 tests/c/test_solver_enumeration.c create mode 100644 tests/c/test_solver_runtime_tileset.c create mode 100644 tests/test_solver_web.mjs create mode 100644 tools/build_solver_web.py diff --git a/.github/workflows/solver-web.yml b/.github/workflows/solver-web.yml new file mode 100644 index 0000000..1af3d3a --- /dev/null +++ b/.github/workflows/solver-web.yml @@ -0,0 +1,76 @@ +name: Browser solver package + +on: + push: + tags: ['solver-web-v*'] + pull_request: + paths: + - 'packages/solver-web/**' + - 'tools/build_solver_web.py' + - 'tests/test_solver_web.mjs' + - 'include/wang/**' + - 'src/**' + - '.github/workflows/solver-web.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Install the pinned Emscripten SDK outside the checkout + run: | + git clone --depth 1 --branch 4.0.23 https://github.com/emscripten-core/emsdk.git "$RUNNER_TEMP/emsdk" + "$RUNNER_TEMP/emsdk/emsdk" install 4.0.23 + "$RUNNER_TEMP/emsdk/emsdk" activate 4.0.23 + - name: Build versioned package and check tag + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + run: | + if [ "$REF_TYPE" = tag ]; then + python3 -c 'import json, os; assert os.environ["REF_NAME"] == "solver-web-v" + json.load(open("packages/solver-web/package.json"))["version"]' + python3 tools/build_solver_web.py --release --emcc "$RUNNER_TEMP/emsdk/upstream/emscripten/emcc" + else + python3 tools/build_solver_web.py --emcc "$RUNNER_TEMP/emsdk/upstream/emscripten/emcc" + fi + - name: Check both compiled engines against exhaustive enumeration + run: | + for package in build/solver-web/*/; do + node tests/test_solver_web.mjs "$package" + done + - name: Retain release archive briefly + uses: actions/upload-artifact@v4 + with: + name: solver-web + path: | + build/solver-web/*.tar.gz + build/solver-web/*.sha256 + retention-days: 1 + + release: + if: startsWith(github.ref, 'refs/tags/solver-web-v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: solver-web + path: package + - name: Publish package assets + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + gh release create "$TAG" package/* --verify-tag --title "Browser solver ${TAG#solver-web-v}" --generate-notes diff --git a/Makefile b/Makefile index cc1ddbb..217154b 100644 --- a/Makefile +++ b/Makefile @@ -126,6 +126,10 @@ serial: $(SERIAL_LIBRARY) shared: $(SHARED_LIBRARY) +.PHONY: solver-web +solver-web: + $(PYTHON) tools/build_solver_web.py + openmp: $(OPENMP_LIBRARY) $(SERIAL_LIBRARY): $(SERIAL_OBJECTS) | $(LIB_DIR) diff --git a/README.md b/README.md index a94d511..42baf52 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,15 @@ the data flow and component boundaries. The [worked example](https://xtraid.github.io/tiling-foundry/worked-example/) follows a SAT instance from its source formula to the checked tiling. +## Interactive research companion + +[Wang Studio](https://xtraid.github.io/wang-studio/) is an independent editor +for subsequent tiling research, with its own repository and website. It consumes +the [versioned browser solver package](packages/solver-web/README.md) built here +from the reference and optimized C engines. See the +[integration notes](docs/plans/wang_studio.md). Studio is distinct from the +artifact renderer and requires no Python solver at runtime. + ## Small standalone harness [`harness_wangz3/`](harness_wangz3/) contains a small reimplementation of the diff --git a/docs/plans/wang_studio.md b/docs/plans/wang_studio.md new file mode 100644 index 0000000..83569fc --- /dev/null +++ b/docs/plans/wang_studio.md @@ -0,0 +1,54 @@ +# Wang Studio: independent research editor + +Wang Studio is an independent project for the research following Tiling Foundry, +with its own repository and website. It consumes Foundry's versioned solver +package for authoring tilesets, constructing finite regions and exploring search. + +The browser package is maintained under `packages/solver-web/` in Foundry and +contains the C reference and optimized engines compiled to WebAssembly. Its +documentation is the current package contract. Python/Z3 is not distributed +to the editor. The local Python connector described below remains an optional +development comparison path. Foundry's Pages link to Studio; Studio is not +hosted in the Foundry source repository. + +It is distinct from `renderer/`, which produces figures from verified +Foundry artifacts. Studio owns interaction and experiments; Foundry continues +to own native propagation, search, verification and scientific pipelines. + +The first local connector consumes `libwang.so` through +`wang_solve_optimized_tileset`: the **same optimized core** accepts a runtime +square tileset of 1–32 tiles with colors 0–15. It supports initial domains and +metrics. Canonical trace formats are not accepted for custom tilesets. +The existing 23-tile APIs and the Yang–Zhang pipeline remain available. + +The runtime verifier is `wang_verify_tiling_with_tileset`. Both interfaces +borrow their input arrays. The result lifecycle is unchanged: +`wang_solve_result_destroy` releases returned domains. + +Studio runs solves in worker processes for cancellation/deadlines, checks +solutions independently, and treats timeout as distinct from UNSAT. It does +not copy the solver implementation or use Z3 as a runtime fallback. + +Current local companion checkout: `~/Projects/wang-studio`. Its README +contains startup instructions and the connector's resource limits. Native +adjacency for hexagons and triangles is future work; Studio's support for +editing those geometries does not imply that this square-grid API solves them. + +## Incremental enumeration + +`wang_enumerate_optimized_tileset` shares the optimized search core. Each +verified solution is passed to a borrowed-domain visitor. Returning true +continues via trail rollback; false stops. A visitor may block, retaining the +DFS stack, to implement interactive next-solution requests. Return codes are +`0` for exhaustive completion, `1` for visitor-requested stop, and `-1` for error. + +Enumeration deduplicates identical edge tuples within each initial cell domain. +It disables the single-solution shortcut that arbitrarily picks one tile for an +isolated cell. The canonical decision solver and its trace formats retain their +previous behavior. The caller owns the count, so no fixed-width counter can +silently overflow inside the core. + +Studio's worker counts with Python integers, publishes throttled progress +atomically, and waits for next/count commands inside the native visitor. Stop +and deadlines retain only a lower bound; exhaustive completion publishes the +exact total. Displayed solutions do not replace the original fixed constraints. diff --git a/include/wang/solver.h b/include/wang/solver.h index 7b51e07..78cc1ad 100644 --- a/include/wang/solver.h +++ b/include/wang/solver.h @@ -130,6 +130,47 @@ WangSolveStatus wang_solve_optimized( WangSolveResult *out_result ); +/* Runtime square-grid tilesets use the same optimized search mechanisms. + * Initial integration limit: 1..32 tiles, colors 0..COLOR_COUNT-1. + * Tile position is its ID; generalized metadata is ignored. No rotations. + * Domain bit i refers to tileset[i], including bit 31 for a 32-tile set. + * Supports initial_domains and COLLECT_METRICS; canonical trace formats are + * intentionally not accepted for non-canonical tilesets. Borrowed inputs. + */ +#define WANG_RUNTIME_MAX_TILES 32u +WangSolveStatus wang_solve_optimized_tileset( + const Region *region, + const WangTile *tileset, + size_t tileset_count, + const WangSolverOptions *options, + WangSolveResult *out_result +); + +/* Enumerate distinct edge-color configurations using the optimized DFS. + * The visitor receives borrowed singleton domains and returns true to continue + * or false to stop. It may block to pause without losing the search stack. + * Duplicate edge tuples are collapsed per cell, respecting initial domains. + * Return: -1 error, 0 exhaustive completion, 1 visitor-requested stop. + * The caller owns any counter (so counting is not limited to uint64_t). + * No rotations. Inputs must remain immutable until this call returns. + */ +typedef bool (*WangSolutionVisitor)( + const uint32_t *domains, size_t cell_count, + const WangSolverMetrics *metrics, void *userdata +); +int wang_enumerate_optimized_tileset( + const Region *region, const WangTile *tileset, size_t tileset_count, + const WangSolverOptions *options, WangSolutionVisitor visitor, + void *userdata, WangSolverMetrics *metrics +); + +/* Same runtime enumeration contract using the reference mechanisms. */ +int wang_enumerate_serial_tileset( + const Region *region, const WangTile *tileset, size_t tileset_count, + const WangSolverOptions *options, WangSolutionVisitor visitor, + void *userdata, WangSolverMetrics *metrics +); + /* Release the owned snapshot and reset every field. Accepts NULL. */ void wang_solve_result_destroy(WangSolveResult *result); diff --git a/include/wang/verify.h b/include/wang/verify.h index 8156076..caf3cdd 100644 --- a/include/wang/verify.h +++ b/include/wang/verify.h @@ -28,4 +28,10 @@ WangVerifyStatus wang_verify_tiling( size_t tile_count ); +/* Independent verification against an explicitly supplied square tileset. */ +WangVerifyStatus wang_verify_tiling_with_tileset( + const Region *region, const TileId *tiles, size_t tile_count, + const WangTile *tileset, size_t tileset_count +); + #endif /* WANG_VERIFY_H */ diff --git a/packages/solver-web/README.md b/packages/solver-web/README.md new file mode 100644 index 0000000..1d7270f --- /dev/null +++ b/packages/solver-web/README.md @@ -0,0 +1,80 @@ +# Tiling Foundry browser solver + +This package is built and owned by Tiling Foundry. Independent applications +consume the versioned artifacts without copying the native solver source. +It runs the existing reference and optimized C search mechanisms in a dedicated Web Worker. +No Python service, npm runtime dependencies, shared memory, cross-origin +isolation headers, or paid compute service is required. + +## Build + +Activate Emscripten **4.0.23**, then run from the Foundry root: + +```sh +python tools/build_solver_web.py +``` + +The archive and its SHA-256 are written to `build/solver-web/`. Development +versions include a digest of the actual source inputs; the manifest also records +the Git revision and whether the checkout was dirty. `--release` requires a +clean checkout and uses the version in `packages/solver-web/package.json`. + +## Consume + +Serve the entire extracted package directory over HTTP(S). Keep its relative +paths intact, including the Worker, JavaScript loader, Wasm and manifest. + +```js +import {Solver, capabilities} from './vendor/foundry/client.mjs'; +const solver = new Solver(); +const engine = await capabilities(); +const {id} = await solver.start({ + geometry: 'square', solver: 'optimized', palette: ['#ffffff'], tiles: [[0, 0, 0, 0]], + cells: [{id: '0,0', required: [null, null, null, null]}], + enumeration_mode: 'step', timeout_seconds: 30, +}); +// Poll solver.get(id). When paused: solver.resume(id, 'next' | 'count'). +// solver.cancel(id) terminates the Worker; solver.close() closes all jobs. +``` + +One live search per Solver instance. Statuses: `running`, `paused`, `complete`, +`cancelled`, `timeout`, `expired`, `error`. Only exhaustive `complete` yields +`exact: true`. Counts are arbitrary-precision decimal strings; metrics are +decimal strings too. Duplicate edge tuples are counted once, subject to initial +domains. Palette indices are symbolic identities, even when RGB values repeat. + +Square edges are `(N,E,S,W)`, with fixed orientations. Limits are 32 tiles, +16 used colors, 4096 active cells, 16384 bounding-box cells, and 128 palette +entries. `fixed` optionally pins an exact edge tuple. `required` constrains each +side; `null` means unconstrained. Negative coordinates, holes and disconnected +inputs are supported, but applications may impose stricter research profiles. + +`solver` selects `optimized` (default) or `reference`. Both execute the shared +Foundry C core with their respective mechanism flags; Python/Z3 is not part of +this package. The two native reference/oracle paths remain available in Foundry. + +The package validates input and independently checks published assignments in +JavaScript. Native Foundry verifies every solution before invoking the visitor. +Counting retains a single snapshot and a counter, not a list of solutions. + +## Pause, stop and deadlines + +Asyncify suspends the existing C callback for interactive stepping, preserving +the DFS stack and trail. Counting yields to the Worker event loop at throttled +progress updates. This adds no alternate search implementation. Asyncify has a +runtime cost; native and browser performance must be benchmarked separately. + +The controlling thread enforces a deadline per active segment, including when +the native search has found no solution. Progress never resets that deadline. +Pause switches to a ten-minute idle expiry; Next/Count starts a new segment. +Stop/timeout terminates the Worker and retains the last published lower bound. +Browser timer throttling can delay enforcement in background tabs. A closed +page ends its computations; there is no server-side persistence. + +This first package exposes only runtime square enumeration. Explicit graph +adjacency, additional geometries and generalized-piece constraints are future +engine features, not inferred from the editor's drawing capabilities. + +Consumers should retain `manifest.json` with experiment results, pin releases, +and update only between searches. Integrity hashes identify artifacts; a hash +does not establish trust in an otherwise untrusted download source. diff --git a/packages/solver-web/bridge.c b/packages/solver-web/bridge.c new file mode 100644 index 0000000..314ae34 --- /dev/null +++ b/packages/solver-web/bridge.c @@ -0,0 +1,55 @@ +/* Browser ABI only. Propagation, enumeration and verification stay in Foundry. */ +#include "wang/solver.h" +#include +#include + +EM_JS(int, publish_solution, (const uint32_t *domains, const uint64_t *metrics), { + return Module['onSolution'](domains, metrics); +}); + +EM_ASYNC_JS(int, wait_for_browser, (int action), { + return await Module['onWait'](action); +}); + +static bool visit(const uint32_t *domains, size_t count, + const WangSolverMetrics *metrics, void *unused) +{ + (void)count; + (void)unused; + const uint64_t counters[3] = { + metrics->dfs_nodes, metrics->backtracks, metrics->domain_reductions + }; + const int action = publish_solution(domains, counters); + return action == 0 || wait_for_browser(action) != 0; +} + +EMSCRIPTEN_KEEPALIVE +int foundry_enumerate(int32_t width, int32_t height, const uint8_t *active, + const uint8_t *boundary, const uint8_t *edges, + size_t tile_count, const uint32_t *initial, int reference) +{ + if (width <= 0 || height <= 0 || (uint64_t)width * (uint64_t)height > 16384 || + !active || !boundary || !edges || !initial || + tile_count == 0 || tile_count > WANG_RUNTIME_MAX_TILES) return -1; + Region region = {0}; + if (!region_init(®ion, width, height)) return -1; + WangTile tiles[WANG_RUNTIME_MAX_TILES] = {0}; + for (size_t t = 0; t < tile_count; ++t) { + tiles[t].id = (TileId)t; + memcpy(tiles[t].edge, edges + t * DIR_COUNT, DIR_COUNT); + } + for (size_t i = 0; i < region.cell_count; ++i) { + region.cells[i].active = active[i] != 0; + memcpy(region.cells[i].boundary, boundary + i * DIR_COUNT, DIR_COUNT); + } + const WangSolverOptions options = { + .flags = WANG_SOLVE_COLLECT_METRICS, + .initial_domains = initial, + .initial_domain_count = region.cell_count + }; + const int result = reference + ? wang_enumerate_serial_tileset(®ion, tiles, tile_count, &options, visit, NULL, NULL) + : wang_enumerate_optimized_tileset(®ion, tiles, tile_count, &options, visit, NULL, NULL); + region_destroy(®ion); + return result; +} diff --git a/packages/solver-web/client.mjs b/packages/solver-web/client.mjs new file mode 100644 index 0000000..5993d85 --- /dev/null +++ b/packages/solver-web/client.mjs @@ -0,0 +1,106 @@ +import {prepare, limits} from './prepare.mjs'; + +const terminal = new Set(['complete', 'cancelled', 'timeout', 'expired', 'error']); +let manifestPromise; +export async function capabilities() { + manifestPromise ??= fetch(new URL('./manifest.json', import.meta.url)).then(async response => { + if (!response.ok) throw Error('Pacchetto solver non disponibile.'); + const manifest = await response.json(); + if (manifest.api_version !== 1) throw Error('Versione del pacchetto solver non compatibile.'); + return manifest; + }).catch(error => { manifestPromise = null; throw error; }); + return {solver: 'optimized', solvers: ['optimized', 'reference'], runtime: 'wasm', geometries: ['square'], + enumeration: true, duplicate_tiles: 'collapsed', ...limits, + engine: await manifestPromise}; +} + +export class Solver { + constructor() { this.jobs = new Map(); } + + async start(payload) { + const copy = structuredClone(payload); + const {timeout, solver} = prepare(copy); + this.reap(); + if ([...this.jobs.values()].some(job => !terminal.has(job.result.status))) { + throw Error('È già presente una ricerca. Premi Stop prima di avviarne un’altra.'); + } + // Reserve the slot before awaiting the manifest to prevent concurrent starts. + const id = crypto.randomUUID(); + const job = {worker: null, timer: null, generation: 0, timeout, finished: null, + result: {status: 'running', solver, runtime: 'wasm', count: '0', exact: false, generation: 0}}; + this.jobs.set(id, job); + this.arm(job, timeout * 1000, 'timeout'); + try { + const caps = await capabilities(); + if (terminal.has(job.result.status)) return {id, ...job.result}; + job.result.engine = caps.engine; + const worker = new Worker(new URL('./worker.mjs', import.meta.url), {type: 'module'}); + job.worker = worker; + worker.onmessage = ({data}) => { + if (terminal.has(job.result.status) || data.generation != null && data.generation !== job.generation) return; + if (data.status === 'error') { this.finish(job, 'error', data.message); return; } + job.result = {...job.result, ...data}; + if (data.status === 'paused') this.arm(job, 600000, 'expired'); + else if (terminal.has(data.status)) this.finish(job, data.status); + // Progress does not reset the active deadline; resume() starts a segment. + }; + worker.onerror = event => { event.preventDefault(); this.finish(job, 'error', event.message || 'Errore del Worker.'); }; + worker.onmessageerror = () => this.finish(job, 'error', 'Risposta del Worker non leggibile.'); + worker.postMessage({type: 'start', payload: copy}); + return {id, ...job.result}; + } catch (error) { + this.finish(job, 'error', error.message); + throw error; + } + } + + arm(job, milliseconds, outcome) { + clearTimeout(job.timer); + job.timer = setTimeout(() => this.finish(job, outcome), milliseconds); + } + + finish(job, status, message) { + if (job.finished !== null) return; + clearTimeout(job.timer); + job.worker?.terminate(); + job.worker = null; + job.finished = Date.now(); + job.result = {...job.result, status, exact: status === 'complete' && job.result.exact === true}; + if (message) job.result.message = message; + } + + get(id) { + const job = this.jobs.get(id); + if (!job) throw Error('Ricerca non trovata.'); + return structuredClone(job.result); + } + + cancel(id) { + const job = this.jobs.get(id); + if (!job) throw Error('Ricerca non trovata.'); + if (!terminal.has(job.result.status)) this.finish(job, 'cancelled'); + return this.get(id); + } + + resume(id, action) { + const job = this.jobs.get(id); + if (!job || job.result.status !== 'paused' || !['next', 'count'].includes(action)) { + throw Error('La ricerca non è in attesa di una soluzione successiva.'); + } + job.generation++; + job.result = {...job.result, status: 'running', exact: false, generation: job.generation}; + this.arm(job, job.timeout * 1000, 'timeout'); + job.worker.postMessage({type: 'resume', action, generation: job.generation}); + return this.get(id); + } + + reap() { + for (const [id, job] of this.jobs) { + if (job.finished !== null && Date.now() - job.finished > 600000) this.jobs.delete(id); + } + } + + close() { + for (const job of this.jobs.values()) if (!terminal.has(job.result.status)) this.finish(job, 'cancelled'); + } +} diff --git a/packages/solver-web/package.json b/packages/solver-web/package.json new file mode 100644 index 0000000..54d9ccc --- /dev/null +++ b/packages/solver-web/package.json @@ -0,0 +1,8 @@ +{ + "name": "@tiling-foundry/solver-web", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "exports": "./client.mjs", + "description": "Tiling Foundry reference and optimized square solvers in a dedicated browser Worker" +} diff --git a/packages/solver-web/prepare.mjs b/packages/solver-web/prepare.mjs new file mode 100644 index 0000000..40fcbb3 --- /dev/null +++ b/packages/solver-web/prepare.mjs @@ -0,0 +1,101 @@ +/* Input validation and independent witness checking. No search implementation. */ +export const limits = Object.freeze({ + max_tiles: 32, max_used_colors: 16, max_cells: 4096, max_area: 16384, + max_palette: 128, default_timeout_seconds: 30, +}); +const offsets = [[0, -1], [1, 0], [0, 1], [-1, 0]]; + +export function prepare(data) { + if (!data || data.geometry !== 'square') throw Error('Il solver supporta per ora i quadrati.'); + const {palette, tiles, cells} = data; + if (!Array.isArray(palette) || !palette.length || palette.length > limits.max_palette || + !palette.every(c => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c))) { + throw Error('Palette non valida.'); + } + function edges(value, nullable = false) { + if (!Array.isArray(value) || value.length !== 4 || !value.every(v => + (nullable && v === null) || (Number.isInteger(v) && v >= 0 && v < palette.length))) { + throw Error('Una tessera o un vincolo contiene lati non validi.'); + } + return value; + } + if (!Array.isArray(tiles) || !tiles.length || tiles.length > limits.max_tiles) { + throw Error('Il solver richiede da 1 a 32 tessere.'); + } + tiles.forEach(t => edges(t)); + if (!Array.isArray(cells) || !cells.length || cells.length > limits.max_cells) { + throw Error('La regione deve contenere da 1 a 4096 celle.'); + } + const timeout = data.timeout_seconds ?? limits.default_timeout_seconds; + if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout < 1 || timeout > 120) { + throw Error('Tempo limite non valido (1–120 secondi).'); + } + const mode = data.enumeration_mode ?? 'step'; + if (!['step', 'count'].includes(mode)) throw Error('Modalità di enumerazione non valida.'); + const solver = data.solver ?? 'optimized'; + if (!['optimized', 'reference'].includes(solver)) throw Error('Solver non supportato.'); + const records = new Map(); + const used = new Set(tiles.flat()); + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const cell of cells) { + if (!cell || typeof cell.id !== 'string' || !/^-?\d{1,7},-?\d{1,7}$/.test(cell.id)) { + throw Error('Coordinate non valide.'); + } + const [x, y] = cell.id.split(',').map(Number); + const key = `${x},${y}`; + if (Math.abs(x) > 1000000 || Math.abs(y) > 1000000 || records.has(key)) { + throw Error('Coordinate duplicate o fuori intervallo.'); + } + edges(cell.required, true); + if (cell.fixed != null) edges(cell.fixed); + cell.required.forEach(v => { if (v !== null) used.add(v); }); + cell.fixed?.forEach(v => used.add(v)); + records.set(key, {...cell, x, y}); + x0 = Math.min(x0, x); x1 = Math.max(x1, x); + y0 = Math.min(y0, y); y1 = Math.max(y1, y); + } + const width = x1 - x0 + 1, height = y1 - y0 + 1, area = width * height; + if (area > limits.max_area) throw Error('Il rettangolo della regione supera 16384 celle.'); + if (used.size > limits.max_used_colors) throw Error('Il solver supporta al massimo 16 colori usati.'); + // Colors are symbolic palette indices. Equal RGB values remain distinct. + const remap = new Map([...used].sort((a, b) => a - b).map((v, i) => [v, i])); + const active = new Uint8Array(area), boundary = new Uint8Array(area * 4).fill(255); + const domains = new Uint32Array(area), indices = new Map(); + for (const cell of records.values()) { + const index = (cell.y - y0) * width + cell.x - x0; + indices.set(cell.id, index); + active[index] = 1; + tiles.forEach((tile, t) => { + if ((!cell.fixed || tile.every((v, d) => v === cell.fixed[d])) && + cell.required.every((v, d) => v === null || tile[d] === v)) { + domains[index] = (domains[index] | (1 << t)) >>> 0; + } + }); + offsets.forEach(([dx, dy], d) => { + if (!records.has(`${cell.x + dx},${cell.y + dy}`) && cell.required[d] !== null) { + boundary[index * 4 + d] = remap.get(cell.required[d]); + } + }); + } + return {width, height, active, boundary, domains, indices, records, tiles, + edges: new Uint8Array(tiles.flat().map(v => remap.get(v))), timeout, mode, solver}; +} + +export function checkAssignments(prepared, assignments) { + if (Object.keys(assignments).length !== prepared.records.size) throw Error('Soluzione incompleta.'); + for (const cell of prepared.records.values()) { + const id = assignments[cell.id], tile = prepared.tiles[id]; + if (!Number.isInteger(id) || !tile || + (cell.fixed && tile.some((v, d) => v !== cell.fixed[d]))) { + throw Error('Soluzione non valida o tessera fissata modificata.'); + } + offsets.forEach(([dx, dy], d) => { + const other = prepared.records.get(`${cell.x + dx},${cell.y + dy}`); + if ((cell.required[d] !== null && tile[d] !== cell.required[d]) || + (other && tile[d] !== prepared.tiles[assignments[other.id]]?.[(d + 2) % 4])) { + throw Error('La soluzione contiene lati incompatibili.'); + } + }); + } + return assignments; +} diff --git a/packages/solver-web/worker.mjs b/packages/solver-web/worker.mjs new file mode 100644 index 0000000..e9c3d51 --- /dev/null +++ b/packages/solver-web/worker.mjs @@ -0,0 +1,85 @@ +import createModule from './foundry.mjs'; +import {prepare, checkAssignments} from './prepare.mjs'; + +let resume = null, started = false; +self.onmessage = ({data}) => { + if (data.type === 'start' && !started) { + started = true; + run(data.payload).catch(error => self.postMessage({status: 'error', exact: false, message: error.message})); + } else if (data.type === 'resume' && resume && ['next', 'count'].includes(data.action)) { + const accept = resume; + resume = null; + accept(data); + } +}; + +async function run(payload) { + const prepared = prepare(payload); + const module = await createModule(); + let count = 0n, generation = 0, mode = prepared.mode, assignments = null; + let metrics = {}, lastEmit = 0, pausedAt = 0, pausedTime = 0; + const began = performance.now(); + const publish = (status, exact = false) => self.postMessage({ + status, exact, count: count.toString(), assignments, metrics, generation, + elapsed_ms: Math.round((performance.now() - began - pausedTime) * 100) / 100, + }); + const snapshot = pointer => { + const result = {}; + for (const [id, index] of prepared.indices) { + const mask = module.HEAPU32[(pointer >>> 2) + index]; + if (!mask || (mask & (mask - 1)) !== 0) throw Error('Dominio non singleton nella soluzione.'); + result[id] = 31 - Math.clz32(mask); + } + return checkAssignments(prepared, result); + }; + module.onSolution = (domains, counters) => { + count++; + const now = performance.now(); + if (mode !== 'step' && count !== 1n && now - lastEmit < 150) return 0; + // Copy only published snapshots; native Foundry verifies every witness. + if (mode === 'step' || assignments === null) assignments = snapshot(domains); + const values = new DataView(module.HEAPU8.buffer); + metrics = Object.fromEntries(['nodes', 'backtracks', 'reductions'].map((key, i) => + [key, values.getBigUint64(counters + i * 8, true).toString()])); + lastEmit = now; + if (mode === 'step') { + pausedAt = now; + publish('paused'); + return 1; + } + publish('running'); + return 2; + }; + module.onWait = async action => { + if (action === 1) { + const command = await new Promise(resolve => { resume = resolve; }); + generation = command.generation; + mode = command.action === 'count' ? 'count' : 'step'; + pausedTime += performance.now() - pausedAt; + publish('running'); + } else { + await new Promise(resolve => setTimeout(resolve, 0)); + } + return 1; + }; + const allocations = []; + const allocate = array => { + const pointer = module._malloc(array.byteLength); + if (!pointer) throw Error('Memoria insufficiente per la ricerca.'); + allocations.push(pointer); + module.HEAPU8.set(new Uint8Array(array.buffer, array.byteOffset, array.byteLength), pointer); + return pointer; + }; + publish('running'); + try { + const args = [prepared.width, prepared.height, allocate(prepared.active), + allocate(prepared.boundary), allocate(prepared.edges), prepared.tiles.length, + allocate(prepared.domains), Number(prepared.solver === 'reference')]; + const status = await module.ccall('foundry_enumerate', 'number', + Array(8).fill('number'), args, {async: true}); + if (status !== 0) throw Error('Il motore ha interrotto la ricerca senza esaurirla.'); + publish('complete', true); + } finally { + allocations.forEach(pointer => module._free(pointer)); + } +} diff --git a/src/solver/solver_serial.c b/src/solver/solver_serial.c index d125a1f..073b33e 100644 --- a/src/solver/solver_serial.c +++ b/src/solver/solver_serial.c @@ -21,12 +21,12 @@ enum { MRV_MIN_DOMAIN_SIZE = 2, - MRV_BUCKET_COUNT = TILE_COUNT - MRV_MIN_DOMAIN_SIZE + 1 + MRV_BUCKET_COUNT = WANG_RUNTIME_MAX_TILES - MRV_MIN_DOMAIN_SIZE + 1 }; typedef struct { uint32_t edge_mask[DIR_COUNT][COLOR_COUNT]; - uint32_t compat[DIR_COUNT][TILE_COUNT]; + uint32_t compat[DIR_COUNT][WANG_RUNTIME_MAX_TILES]; } SolverTables; typedef struct { @@ -68,10 +68,21 @@ typedef struct { size_t allocated_bytes; } SearchStack; +typedef struct { + WangSolutionVisitor visitor; + void *userdata; + WangSolverMetrics *metrics; +} Enumeration; + typedef struct { const Region *region; + Enumeration *enumeration; SolverTables tables; - ByteSupportTables *byte_support; + uint32_t *byte_support; + size_t domain_byte_count; + const WangTile *tileset; + size_t tileset_count; + uint32_t all_tiles; uint32_t *domains; uint8_t *neighbor_mask; @@ -206,34 +217,36 @@ static bool result_is_destroyed(const WangSolveResult *result) metrics_are_zero(&result->metrics); } -static void build_solver_tables(SolverTables *tables) +static void build_solver_tables(SolverTables *tables, + const WangTile *tileset, size_t tile_count) { memset(tables, 0, sizeof(*tables)); - for (TileId tile = 0; tile < TILE_COUNT; ++tile) { + for (TileId tile = 0; tile < tile_count; ++tile) { for (Dir dir = N; dir < DIR_COUNT; ++dir) { - const ColorId color = TILESET[tile].edge[dir]; + const ColorId color = tileset[tile].edge[dir]; tables->edge_mask[dir][color] |= UINT32_C(1) << tile; } } - for (TileId tile = 0; tile < TILE_COUNT; ++tile) { + for (TileId tile = 0; tile < tile_count; ++tile) { for (Dir dir = N; dir < DIR_COUNT; ++dir) { - const ColorId color = TILESET[tile].edge[dir]; + const ColorId color = tileset[tile].edge[dir]; tables->compat[dir][tile] = tables->edge_mask[opposite(dir)][color]; } } } -static bool solver_tables_are_valid(const SolverTables *tables) +static bool solver_tables_are_valid(const SolverTables *tables, + const WangTile *tileset, size_t tile_count) { - for (TileId a = 0; a < TILE_COUNT; ++a) { - for (TileId b = 0; b < TILE_COUNT; ++b) { + for (TileId a = 0; a < tile_count; ++a) { + for (TileId b = 0; b < tile_count; ++b) { for (Dir dir = N; dir < DIR_COUNT; ++dir) { const bool cached = (tables->compat[dir][a] & (UINT32_C(1) << b)) != 0; - if (cached != wang_tiles_match(&TILESET[a], dir, &TILESET[b])) { + if (cached != wang_tiles_match(&tileset[a], dir, &tileset[b])) { return false; } } @@ -402,7 +415,7 @@ static size_t first_set_bit_u32(uint32_t value) static bool mrv_size_is_indexed(unsigned size) { - return size >= MRV_MIN_DOMAIN_SIZE && size <= TILE_COUNT; + return size >= MRV_MIN_DOMAIN_SIZE && size <= WANG_RUNTIME_MAX_TILES; } static size_t mrv_bucket_for_size(unsigned size) @@ -461,7 +474,8 @@ static bool mrv_index_move( new_size = old_size - removed_count; } else if (removed == 0) { const unsigned added_count = domain_popcount(added); - if (old_size > TILE_COUNT - added_count) { + if (added_count > state->tileset_count || + old_size > state->tileset_count - added_count) { return false; } new_size = old_size + added_count; @@ -531,7 +545,7 @@ static bool allocate_mrv_index(SolverState *state) size_t bucket_bytes; size_t size_bytes; if (!checked_mul_size( - MRV_BUCKET_COUNT, + state->tileset_count > 1 ? state->tileset_count - 1 : 1, state->mrv_word_count, &bucket_word_count ) || @@ -833,10 +847,11 @@ static uint32_t supported_neighbor_domain( { uint32_t supported = 0; if (state->byte_support != NULL) { - for (size_t byte = 0; byte < WANG_DOMAIN_BYTE_COUNT; ++byte) { + for (size_t byte = 0; byte < state->domain_byte_count; ++byte) { const uint8_t value = (uint8_t)domain; if (value != 0) { - supported |= state->byte_support->support[dir][byte][value]; + supported |= state->byte_support[(dir * state->domain_byte_count + byte) * + WANG_SUPPORT_BYTE_VALUE_COUNT + value]; if (state->collect_metrics) { ++state->metrics.support_byte_lookups; } @@ -1034,7 +1049,7 @@ static size_t select_mrv_cell(SolverState *state) } size_t selected = SIZE_MAX; - unsigned best_size = TILE_COUNT + 1u; + unsigned best_size = (unsigned)state->tileset_count + 1u; for (size_t i = 0; i < state->cell_count; ++i) { if (!state->region->cells[i].active) { @@ -1156,6 +1171,8 @@ static void note_search_stack_capacity( } } +static bool verify_sat_domains(const SolverState *state); + static WangSolveStatus search( SolverState *state, SearchStackMode stack_mode @@ -1165,6 +1182,12 @@ static WangSolveStatus search( if (state->resolved_count == state->active_count) { state->best_depth = 0; + if (state->enumeration != NULL) { + if (!verify_sat_domains(state)) return WANG_SOLVE_ERROR; + return state->enumeration->visitor( + state->domains, state->cell_count, &state->metrics, + state->enumeration->userdata) ? WANG_SOLVE_UNSAT : WANG_SOLVE_SAT; + } return WANG_SOLVE_SAT; } @@ -1322,6 +1345,16 @@ static WangSolveStatus search( if (state->resolved_count == state->active_count) { state->best_depth = branch_depth; + if (state->enumeration != NULL) { + if (!verify_sat_domains(state)) break; + if (state->enumeration->visitor( + state->domains, state->cell_count, &state->metrics, + state->enumeration->userdata)) { + if (!rollback_to(state, mark)) break; + if (state->collect_metrics) ++state->metrics.backtracks; + continue; + } + } status = WANG_SOLVE_SAT; break; } @@ -1450,8 +1483,8 @@ static bool initialize_domains( ++state->active_count; uint32_t domain = initial_domains != NULL ? initial_domains[index] - : WANG_DOMAIN_ALL; - if (state->collect_metrics && domain != WANG_DOMAIN_ALL) { + : state->all_tiles; + if (state->collect_metrics && domain != state->all_tiles) { ++state->metrics.domain_reductions; } @@ -1480,8 +1513,26 @@ static bool initialize_domains( } } - /* An isolated cell cannot constrain any other choice. */ - if (state->neighbor_mask[index] == 0 && + if (state->enumeration != NULL) { + uint32_t unique = 0; + for (size_t t = 0; t < state->tileset_count; ++t) { + const uint32_t bit = UINT32_C(1) << t; + if ((domain & bit) == 0) continue; + bool duplicate = false; + for (size_t u = 0; u < t; ++u) { + if ((unique & (UINT32_C(1) << u)) != 0 && + memcmp(state->tileset[t].edge, state->tileset[u].edge, + sizeof(state->tileset[t].edge)) == 0) { + duplicate = true; + break; + } + } + if (!duplicate) unique |= bit; + } + domain = unique; + } + /* Collapsing isolated choices is valid for SAT, not counting. */ + if (state->enumeration == NULL && state->neighbor_mask[index] == 0 && !domain_is_singleton(domain) && domain != 0) { domain &= UINT32_C(0) - domain; if (state->collect_metrics) { @@ -1526,7 +1577,8 @@ static bool verify_sat_domains(const SolverState *state) } const bool valid = complete && - wang_verify_tiling(state->region, tiles, state->cell_count) == + wang_verify_tiling_with_tileset(state->region, tiles, state->cell_count, + state->tileset, state->tileset_count) == WANG_VERIFY_VALID; free(tiles); return valid; @@ -1558,7 +1610,8 @@ static bool solver_options_are_valid(const WangSolverOptions *options) static bool initial_domains_are_valid( const Region *region, - const WangSolverOptions *options + const WangSolverOptions *options, + uint32_t all_tiles ) { if (options == NULL) { @@ -1573,7 +1626,7 @@ static bool initial_domains_are_valid( for (size_t i = 0; i < region->cell_count; ++i) { const uint32_t mask = options->initial_domains[i]; - if ((mask & ~WANG_DOMAIN_ALL) != 0 || + if ((mask & ~all_tiles) != 0 || (!region->cells[i].active && mask != 0)) { return false; } @@ -1597,7 +1650,10 @@ static WangSolveStatus solve_wang_core( WangSolveResult *out_result, SolverMechanisms mechanisms, const WangSolveTraceOptions *trace_options, - WangSolveTrace *out_trace + WangSolveTrace *out_trace, + const WangTile *tileset, + size_t tileset_count, + Enumeration *enumeration ) { if (!result_is_destroyed(out_result) || @@ -1609,10 +1665,18 @@ static WangSolveStatus solve_wang_core( return WANG_SOLVE_ERROR; } - if (!region_validate(region)) { + if (tileset == NULL || tileset_count == 0 || + tileset_count > WANG_RUNTIME_MAX_TILES || !region_validate(region)) { return WANG_SOLVE_ERROR; } - if (!initial_domains_are_valid(region, options)) { + for (size_t t = 0; t < tileset_count; ++t) { + for (Dir d = N; d < DIR_COUNT; ++d) { + if (tileset[t].edge[d] >= COLOR_COUNT) return WANG_SOLVE_ERROR; + } + } + const uint32_t all_tiles = tileset_count == 32u + ? UINT32_MAX : (UINT32_C(1) << tileset_count) - UINT32_C(1); + if (!initial_domains_are_valid(region, options, all_tiles)) { return WANG_SOLVE_ERROR; } const size_t cell_count = region->cell_count; @@ -1620,6 +1684,11 @@ static WangSolveStatus solve_wang_core( SolverState state = {0}; state.writer.fd = -1; state.region = region; + state.enumeration = enumeration; + state.tileset = tileset; + state.tileset_count = tileset_count; + state.all_tiles = all_tiles; + state.domain_byte_count = (tileset_count + 7u) / 8u; state.cell_count = cell_count; state.collect_metrics = options != NULL && (options->flags & WANG_SOLVE_COLLECT_METRICS) != 0; @@ -1628,22 +1697,35 @@ static WangSolveStatus solve_wang_core( state.record_trail = mechanisms.record_initial_trail; state.deduplicate_queue = mechanisms.deduplicate_queue; state.index_mrv = mechanisms.index_mrv; - build_solver_tables(&state.tables); - if (!solver_tables_are_valid(&state.tables)) { + build_solver_tables(&state.tables, tileset, tileset_count); + if (!solver_tables_are_valid(&state.tables, tileset, tileset_count)) { return WANG_SOLVE_ERROR; } if (mechanisms.use_bytewise_support) { - state.byte_support = malloc(sizeof(*state.byte_support)); + const size_t entries = DIR_COUNT * state.domain_byte_count * + WANG_SUPPORT_BYTE_VALUE_COUNT; + state.byte_support = calloc(entries, sizeof(*state.byte_support)); if (state.byte_support == NULL) { solver_state_destroy(&state); return WANG_SOLVE_ERROR; } - byte_support_tables_build( - (const ByteSupportCompat *)&state.tables.compat, - state.byte_support - ); + /* Same bytewise union optimization; runtime tile count, no copies of + * the search implementation and no canonical tileset dependency. */ + for (Dir d = N; d < DIR_COUNT; ++d) { + for (size_t byte = 0; byte < state.domain_byte_count; ++byte) { + uint32_t *support = state.byte_support + + (d * state.domain_byte_count + byte) * 256u; + for (unsigned value = 1; value < 256u; ++value) { + const size_t tile = byte * 8u + first_set_tile(value); + support[value] = support[value & (value - 1u)]; + if (tile < tileset_count) { + support[value] |= state.tables.compat[d][tile]; + } + } + } + } if (state.collect_metrics) { - state.metrics.support_table_bytes = sizeof(*state.byte_support); + state.metrics.support_table_bytes = entries * sizeof(uint32_t); } } @@ -1783,6 +1865,12 @@ static WangSolveStatus solve_wang_core( } } + if (enumeration != NULL) { + if (enumeration->metrics != NULL) *enumeration->metrics = state.metrics; + solver_state_destroy(&state); + return status; + } + if (status == WANG_SOLVE_SAT && !verify_sat_domains(&state)) { status = WANG_SOLVE_ERROR; } @@ -1890,7 +1978,8 @@ WangSolveStatus wang_solve_serial( .index_mrv = false, }, NULL, - NULL + NULL, + TILESET, TILE_COUNT, NULL ); } @@ -1913,7 +2002,8 @@ WangSolveStatus wang_solve_optimized( .index_mrv = WANG_OPTIMIZED_MRV_INDEX != 0, }, NULL, - NULL + NULL, + TILESET, TILE_COUNT, NULL ); } @@ -1947,7 +2037,8 @@ WangSolveStatus wang_solve_serial_traced( .index_mrv = false, }, trace_options, - &out_result->trace + &out_result->trace, + TILESET, TILE_COUNT, NULL ); } @@ -1974,6 +2065,76 @@ WangSolveStatus wang_solve_optimized_traced( .index_mrv = WANG_OPTIMIZED_MRV_INDEX != 0, }, trace_options, - &out_result->trace + &out_result->trace, + TILESET, TILE_COUNT, NULL + ); +} + +WangSolveStatus wang_solve_optimized_tileset( + const Region *region, const WangTile *tileset, size_t tileset_count, + const WangSolverOptions *options, WangSolveResult *out_result +) +{ + if (options != NULL && + (options->flags & ~WANG_SOLVE_COLLECT_METRICS) != 0) { + return WANG_SOLVE_ERROR; + } + return solve_wang_core( + region, options, out_result, + (SolverMechanisms) { + .stack_mode = SEARCH_STACK_DYNAMIC, + .record_initial_trail = false, + .transfer_sat_domains = true, + .use_bytewise_support = true, + .deduplicate_queue = WANG_OPTIMIZED_QUEUE_DEDUP != 0, + .index_mrv = WANG_OPTIMIZED_MRV_INDEX != 0, + }, + NULL, NULL, tileset, tileset_count, NULL ); } + +int wang_enumerate_optimized_tileset( + const Region *region, const WangTile *tileset, size_t tileset_count, + const WangSolverOptions *options, WangSolutionVisitor visitor, + void *userdata, WangSolverMetrics *metrics +) +{ + if (metrics != NULL) *metrics = (WangSolverMetrics){0}; + if (visitor == NULL || (options != NULL && + (options->flags & ~WANG_SOLVE_COLLECT_METRICS) != 0)) return -1; + Enumeration enumeration = {visitor, userdata, metrics}; + WangSolveResult unused_result = {0}; + return (int)solve_wang_core( + region, options, &unused_result, + (SolverMechanisms) { + .stack_mode = SEARCH_STACK_DYNAMIC, + .record_initial_trail = false, + .transfer_sat_domains = true, + .use_bytewise_support = true, + .deduplicate_queue = WANG_OPTIMIZED_QUEUE_DEDUP != 0, + .index_mrv = WANG_OPTIMIZED_MRV_INDEX != 0, + }, NULL, NULL, tileset, tileset_count, &enumeration); +} + +int wang_enumerate_serial_tileset( + const Region *region, const WangTile *tileset, size_t tileset_count, + const WangSolverOptions *options, WangSolutionVisitor visitor, + void *userdata, WangSolverMetrics *metrics +) +{ + if (metrics != NULL) *metrics = (WangSolverMetrics){0}; + if (visitor == NULL || (options != NULL && + (options->flags & ~WANG_SOLVE_COLLECT_METRICS) != 0)) return -1; + Enumeration enumeration = {visitor, userdata, metrics}; + WangSolveResult unused_result = {0}; + return (int)solve_wang_core( + region, options, &unused_result, + (SolverMechanisms) { + .stack_mode = SEARCH_STACK_FIXED, + .record_initial_trail = true, + .transfer_sat_domains = false, + .use_bytewise_support = false, + .deduplicate_queue = false, + .index_mrv = false, + }, NULL, NULL, tileset, tileset_count, &enumeration); +} diff --git a/src/verify/verify_tiling.c b/src/verify/verify_tiling.c index 8acd564..24bc68f 100644 --- a/src/verify/verify_tiling.c +++ b/src/verify/verify_tiling.c @@ -1,15 +1,24 @@ #include "wang/verify.h" -WangVerifyStatus wang_verify_tiling( +WangVerifyStatus wang_verify_tiling_with_tileset( const Region *region, const TileId *tiles, - size_t tile_count + size_t tile_count, + const WangTile *tileset, + size_t tileset_count ) { - if (region == NULL || tiles == NULL) { + if (region == NULL || tiles == NULL || tileset == NULL || + tileset_count == 0 || tileset_count > 32u) { return WANG_VERIFY_INVALID_ARGUMENT; } + for (size_t t = 0; t < tileset_count; ++t) { + for (Dir d = N; d < DIR_COUNT; ++d) { + if (tileset[t].edge[d] >= COLOR_COUNT) return WANG_VERIFY_INVALID_ARGUMENT; + } + } + if (!region_validate(region)) { return WANG_VERIFY_INVALID_REGION; } @@ -34,11 +43,11 @@ WangVerifyStatus wang_verify_tiling( if (tile_id == TILE_NONE) { return WANG_VERIFY_INCOMPLETE; } - if (tile_id >= TILE_COUNT) { + if (tile_id >= tileset_count) { return WANG_VERIFY_INVALID_TILE_ID; } - const WangTile *tile = &TILESET[tile_id]; + const WangTile *tile = &tileset[tile_id]; for (Dir dir = N; dir < DIR_COUNT; ++dir) { const ColorId required = cell->boundary[dir]; if (required != COLOR_NONE && tile->edge[dir] != required) { @@ -73,8 +82,8 @@ WangVerifyStatus wang_verify_tiling( neighbor_x, neighbor_y ); - const WangTile *tile = &TILESET[tiles[index]]; - const WangTile *neighbor_tile = &TILESET[tiles[neighbor_index]]; + const WangTile *tile = &tileset[tiles[index]]; + const WangTile *neighbor_tile = &tileset[tiles[neighbor_index]]; if (tile->edge[dir] != neighbor_tile->edge[opposite(dir)]) { return WANG_VERIFY_ADJACENCY_MISMATCH; @@ -85,3 +94,11 @@ WangVerifyStatus wang_verify_tiling( return WANG_VERIFY_VALID; } + +WangVerifyStatus wang_verify_tiling( + const Region *region, const TileId *tiles, size_t tile_count +) +{ + return wang_verify_tiling_with_tileset( + region, tiles, tile_count, TILESET, TILE_COUNT); +} diff --git a/tests/c/test_solver_enumeration.c b/tests/c/test_solver_enumeration.c new file mode 100644 index 0000000..f943e5c --- /dev/null +++ b/tests/c/test_solver_enumeration.c @@ -0,0 +1,63 @@ +#include "wang/solver.h" +#include +#include + +typedef struct { size_t count, limit; uint32_t last; } Capture; +static bool visit(const uint32_t *domains, size_t n, + const WangSolverMetrics *metrics, void *userdata) +{ + Capture *capture = userdata; + assert(domains != NULL && n > 0 && metrics != NULL); + ++capture->count; + capture->last = domains[0]; + return capture->limit == 0 || capture->count < capture->limit; +} + +int main(void) +{ + Region region = {0}; + assert(region_init(®ion, 1, 1)); + assert(region_set_active(®ion, 0, 0, true)); + WangTile tiles[3] = {{.edge={0,0,0,0}}, {.edge={1,1,1,1}}, {.edge={0,0,0,0}}}; + Capture c = {0}; + assert(wang_enumerate_optimized_tileset(®ion,tiles,3,NULL,visit,&c,NULL)==0); + assert(c.count==2); /* Isolated choices cannot be collapsed to one. */ + uint32_t domain=4; /* An allowed duplicate must remain usable. */ + WangSolverOptions options={.initial_domains=&domain,.initial_domain_count=1}; + c=(Capture){0}; + assert(wang_enumerate_optimized_tileset(®ion,tiles,3,&options,visit,&c,NULL)==0); + assert(c.count==1 && c.last==4); + c=(Capture){.limit=1}; + assert(wang_enumerate_optimized_tileset(®ion,tiles,3,NULL,visit,&c,NULL)==1); + assert(c.count==1); + domain=0;c=(Capture){0}; + assert(wang_enumerate_optimized_tileset(®ion,tiles,3,&options,visit,&c,NULL)==0); + assert(c.count==0); + region_destroy(®ion); + + assert(region_init(®ion,2,2)); + WangTile binary[16]={0}; + for (unsigned t=0;t<16;t++) for (unsigned d=0;d<4;d++) binary[t].edge[d]=(t>>d)&1u; + for(int y=0;y<2;y++) for(int x=0;x<2;x++) assert(region_set_active(®ion,x,y,true)); + for(int y=0;y<2;y++) for(int x=0;x<2;x++) { + if(y==0) assert(region_set_boundary(®ion,x,y,N,0)); + if(y==1) assert(region_set_boundary(®ion,x,y,S,0)); + if(x==0) assert(region_set_boundary(®ion,x,y,W,0)); + if(x==1) assert(region_set_boundary(®ion,x,y,E,0)); + } + c=(Capture){0}; + WangSolverMetrics metrics={0}; + options=(WangSolverOptions){.flags=WANG_SOLVE_COLLECT_METRICS}; + assert(wang_enumerate_optimized_tileset(®ion,binary,16,&options,visit,&c,&metrics)==0); + assert(c.count==16 && metrics.backtracks>0); + c=(Capture){0}; + metrics=(WangSolverMetrics){0}; + assert(wang_enumerate_serial_tileset(®ion,binary,16,&options,visit,&c,&metrics)==0); + assert(c.count==16 && metrics.backtracks>0); + c=(Capture){.limit=1}; + assert(wang_enumerate_serial_tileset(®ion,binary,16,&options,visit,&c,NULL)==1); + assert(c.count==1); + region_destroy(®ion); + puts("test_solver_enumeration: OK"); + return 0; +} diff --git a/tests/c/test_solver_runtime_tileset.c b/tests/c/test_solver_runtime_tileset.c new file mode 100644 index 0000000..c4dc3ef --- /dev/null +++ b/tests/c/test_solver_runtime_tileset.c @@ -0,0 +1,57 @@ +#include "wang/solver.h" +#include "wang/verify.h" +#include +#include + +static Region rectangle(int width, int height) +{ + Region r = {0}; + assert(region_init(&r, width, height)); + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) + assert(region_set_active(&r, x, y, true)); + return r; +} + +int main(void) +{ + WangTile alternating[2] = { + {.edge = {0, 1, 0, 0}}, {.edge = {0, 0, 0, 1}} + }; + Region r = rectangle(2, 1); + assert(region_set_boundary(&r, 0, 0, W, 0)); + assert(region_set_boundary(&r, 1, 0, E, 0)); + WangSolveResult out = {0}; + assert(wang_solve_optimized_tileset(&r, alternating, 2, NULL, &out) == WANG_SOLVE_SAT); + assert(out.domains[0] == 1 && out.domains[1] == 2); + const TileId solution[2] = {0, 1}; + assert(wang_verify_tiling_with_tileset(&r, solution, 2, alternating, 2) == WANG_VERIFY_VALID); + wang_solve_result_destroy(&out); + assert(region_set_boundary(&r, 1, 0, E, 1)); + assert(wang_solve_optimized_tileset(&r, alternating, 2, NULL, &out) == WANG_SOLVE_UNSAT); + wang_solve_result_destroy(&out); + region_destroy(&r); + + /* Exercise bit 31, four-byte support and the largest MRV domain. */ + WangTile full[32] = {0}; + for (int d = 0; d < 4; ++d) full[31].edge[d] = 1; + r = rectangle(3, 2); + uint32_t domains[6] = {UINT32_MAX, UINT32_MAX, UINT32_MAX, + UINT32_MAX, UINT32_MAX, UINT32_C(1) << 31}; + WangSolverOptions options = {.flags = WANG_SOLVE_COLLECT_METRICS, + .initial_domains = domains, .initial_domain_count = 6}; + assert(wang_solve_optimized_tileset(&r, full, 32, &options, &out) == WANG_SOLVE_SAT); + for (int i = 0; i < 6; ++i) assert(out.domains[i] == (UINT32_C(1) << 31)); + assert(out.metrics.support_table_bytes == 4u * 4u * 256u * sizeof(uint32_t)); + wang_solve_result_destroy(&out); + assert(wang_solve_optimized_tileset(&r, full, 32, NULL, &out) == WANG_SOLVE_SAT); + wang_solve_result_destroy(&out); + assert(wang_solve_optimized_tileset(&r, full, 0, NULL, &out) == WANG_SOLVE_ERROR); + assert(wang_solve_optimized_tileset(&r, full, 33, NULL, &out) == WANG_SOLVE_ERROR); + assert(wang_solve_optimized_tileset(&r, full, 1, &options, &out) == WANG_SOLVE_ERROR); + full[0].edge[0] = COLOR_COUNT; + assert(wang_solve_optimized_tileset(&r, full, 32, NULL, &out) == WANG_SOLVE_ERROR); + region_destroy(&r); + puts("test_solver_runtime_tileset: OK"); + return 0; +} diff --git a/tests/test_solver_web.mjs b/tests/test_solver_web.mjs new file mode 100644 index 0000000..3a05d10 --- /dev/null +++ b/tests/test_solver_web.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import {pathToFileURL} from 'node:url'; +import {resolve} from 'node:path'; + +const root = resolve(process.argv[2] || 'build/solver-web/package'); +const {default: createModule} = await import(pathToFileURL(root + '/foundry.mjs')); +const {prepare, checkAssignments} = await import(pathToFileURL(root + '/prepare.mjs')); +const module = await createModule(); +const offsets = [[0,-1],[1,0],[0,1],[-1,0]]; + +function brute(data) { + const results = new Set(), placed = {}; + const visit = index => { + if (index === data.cells.length) { + results.add(JSON.stringify(data.cells.map(c => placed[c.id]))); + return; + } + const cell = data.cells[index], [x,y] = cell.id.split(',').map(Number); + for (const tile of data.tiles) { + if (cell.fixed && tile.some((v,d) => v !== cell.fixed[d])) continue; + if (cell.required.some((v,d) => v !== null && tile[d] !== v)) continue; + if (offsets.some(([dx,dy],d) => { + const other = placed[`${x+dx},${y+dy}`]; + return other && tile[d] !== other[(d+2)%4]; + })) continue; + placed[cell.id] = tile; + visit(index+1); + delete placed[cell.id]; + } + }; + visit(0); + return [...results].sort(); +} + +async function enumerate(data, pause) { + const prepared = prepare(data), pointers = [], results = []; + const allocate = array => { + const pointer = module._malloc(array.byteLength); + pointers.push(pointer); + module.HEAPU8.set(new Uint8Array(array.buffer),pointer); + return pointer; + }; + module.onSolution = domains => { + const assigned = Object.fromEntries([...prepared.indices].map(([id,index]) => + [id,31-Math.clz32(module.HEAPU32[(domains>>>2)+index])])); + checkAssignments(prepared,assigned); + results.push(JSON.stringify(data.cells.map(c => data.tiles[assigned[c.id]]))); + return pause ? 1 : 0; + }; + module.onWait = async () => { await new Promise(r => setTimeout(r,5)); return 1; }; + try { + const args = [prepared.width,prepared.height,allocate(prepared.active), + allocate(prepared.boundary),allocate(prepared.edges),prepared.tiles.length, + allocate(prepared.domains),Number(prepared.solver==='reference')]; + assert.equal(await module.ccall('foundry_enumerate','number',Array(8).fill('number'),args,{async:true}),0); + assert.equal(new Set(results).size,results.length,'No repeated edge configurations'); + return results.sort(); + } finally { pointers.forEach(p => module._free(p)); } +} + +const instance = (tiles,width=2,height=2) => ({geometry:'square',palette:['#ffffff','#000000'],tiles, + cells:Array.from({length:width*height},(_,i)=>({id:`${i%width},${Math.floor(i/width)}`,required:[null,null,null,null]}))}); +let seed = 17821; +const random = n => { seed=(Math.imul(seed,1664525)+1013904223)>>>0; return Math.floor(seed/4294967296*n); }; +for(let i=0;i<100;i++) { + const data = instance(Array.from({length:1+random(4)},()=>Array.from({length:4},()=>random(2)))); + for (const cell of data.cells) cell.required=Array.from({length:4},()=>[null,null,0,1][random(4)]); + if(i%3===0)data.cells[0].fixed=data.tiles[0]; + const expected=brute(data); + for (const solver of ['optimized','reference']) assert.deepEqual(await enumerate({...data,solver}),expected); +} +for (const solver of ['optimized','reference']) { + const data=instance([[0,0,0,0],[1,1,1,1],[0,0,0,0]],1,1); + data.solver=solver; + data.cells.push({id:'-3,-3',required:[null,null,null,null]}); + assert.deepEqual(await enumerate(data,true),brute(data),'Asyncify pause preserves native enumeration'); + const bit31=instance(Array.from({length:32},(_,n)=>[n%16,Math.floor(n/16),0,0]),1,1); + bit31.solver=solver; + bit31.palette=Array(16).fill('#ffffff'); + bit31.cells[0].fixed=bit31.tiles[31]; + assert.deepEqual(await enumerate(bit31),brute(bit31),'Bit 31 and symbolic equal-RGB colors'); + const hole=instance([[0,0,0,0]],3,3); + hole.cells=hole.cells.filter(c=>c.id!=='1,1'); + assert.deepEqual(await enumerate({...hole,solver}),brute(hole)); +} +assert.throws(()=>prepare({...instance([[0,0,0,0]]),geometry:'hex-flat'})); +assert.throws(()=>prepare({...instance([[0,0,0,0]]),timeout_seconds:NaN})); +const duplicate=instance([[0,0,0,0]],1,1); +duplicate.cells.push({id:'00,0',required:[null,null,null,null]}); +assert.throws(()=>prepare(duplicate),/duplicate/); +console.log('PASS: C reference and optimized Wasm vs exhaustive enumeration on 100 instances, async pause, duplicates, disconnected regions, holes, bit 31, validation.'); diff --git a/tools/build_solver_web.py b/tools/build_solver_web.py new file mode 100644 index 0000000..5bf1653 --- /dev/null +++ b/tools/build_solver_web.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Build an independently consumable, fingerprinted browser solver package.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +import tarfile + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = ROOT / 'packages/solver-web' +SOURCES = [ + 'src/core/tile.c', 'src/core/region.c', 'src/solver/solver_serial.c', + 'src/solver/byte_support_table.c', 'src/solver/failed_leaf_trace.c', + 'src/solver/solver_event_trace.c', 'src/verify/verify_tiling.c', + 'packages/solver-web/bridge.c', +] +FLAGS = [ + '-std=c17', '-Wall', '-Wextra', '-Werror', '-O3', '-Iinclude', '--no-entry', + '-sMODULARIZE=1', '-sEXPORT_ES6=1', '-sENVIRONMENT=worker,node', + '-sASYNCIFY=1', '-sASYNCIFY_STACK_SIZE=65536', + '-sALLOW_MEMORY_GROWTH=1', '-sMAXIMUM_MEMORY=268435456', + '-sSTACK_SIZE=1048576', '-sFILESYSTEM=0', + '-sEXPORTED_FUNCTIONS=["_foundry_enumerate","_malloc","_free"]', + '-sEXPORTED_RUNTIME_METHODS=["ccall","HEAPU8","HEAPU32"]', +] + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--emcc', default='emcc') + parser.add_argument('--release', action='store_true', help='Require a clean checkout; use the release version.') + args = parser.parse_args() + compiler = subprocess.check_output([args.emcc, '--version'], text=True).splitlines()[0] + revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + dirty = bool(subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=normal'], cwd=ROOT)) + if args.release and dirty: + parser.error('A release requires a clean committed checkout.') + inputs = sorted(set(SOURCES + [ + str(p.relative_to(ROOT)) for pattern in ('include/**/*.h', 'src/solver/*.h', 'packages/solver-web/*') + for p in ROOT.glob(pattern) if p.is_file() + ] + ['tools/build_solver_web.py', 'LICENSE'])) + source_files = {name: sha256(ROOT / name) for name in inputs} + source_hash = hashlib.sha256(json.dumps(source_files, sort_keys=True).encode()).hexdigest() + descriptor = json.loads((PACKAGE / 'package.json').read_text()) + version = descriptor['version'] if args.release else descriptor['version'] + '-dev.' + source_hash[:12] + if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+(?:-dev\.[0-9a-f]+)?', version): + parser.error('Invalid package version.') + build_root = ROOT / 'build/solver-web' + output = build_root / ('tiling-foundry-solver-web-' + version) + output.mkdir(parents=True, exist_ok=True) + subprocess.run([args.emcc, *FLAGS, *SOURCES, '-o', str(output / 'foundry.mjs')], cwd=ROOT, check=True) + for name in ('client.mjs', 'worker.mjs', 'prepare.mjs', 'README.md'): + shutil.copyfile(PACKAGE / name, output / name) + shutil.copyfile(ROOT / 'LICENSE', output / 'LICENSE') + descriptor['version'] = version + (output / 'package.json').write_text(json.dumps(descriptor, indent=2) + '\n') + artifacts = ['foundry.mjs', 'foundry.wasm', 'client.mjs', 'worker.mjs', 'prepare.mjs', + 'README.md', 'LICENSE', 'package.json'] + manifest = dict( + name=descriptor['name'], version=version, api_version=1, solvers=['optimized', 'reference'], + runtime='wasm', geometries=['square'], + source=dict(repository='https://github.com/xtraid/tiling-foundry', revision=revision, + dirty=dirty, sha256=source_hash, files=source_files), + build=dict(compiler=compiler, flags=FLAGS), + limits=dict(max_tiles=32, max_used_colors=16, max_cells=4096, max_area=16384), + files={name: dict(sha256=sha256(output / name), size=(output / name).stat().st_size) for name in artifacts}, + ) + (output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') + archive = build_root / (output.name + '.tar.gz') + with tarfile.open(archive, 'w:gz') as tar: + for name in artifacts + ['manifest.json']: + tar.add(output / name, arcname='package/' + name) + digest = sha256(archive) + archive.with_name(archive.name + '.sha256').write_text(f'{digest} {archive.name}\n') + print(json.dumps(dict(package=str(output), archive=str(archive), sha256=digest, version=version), indent=2)) + + +if __name__ == '__main__': + main() From 0a59839a5ff6962caae77e0ba4f89a1fb2afd167 Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Sat, 10 Oct 2026 03:09:03 +0200 Subject: [PATCH 2/7] Link Wang Studio as an independent research editor --- docs/_includes/footer.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html index 200154c..f065359 100644 --- a/docs/_includes/footer.html +++ b/docs/_includes/footer.html @@ -4,6 +4,8 @@

Source on GitHub + Wang Studio · independent research editor + {{ site.time | date: "%Y" }}

From b6ee1b0d25a94e77db2d218879fe95b1139cec13 Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Sat, 10 Oct 2026 03:19:25 +0200 Subject: [PATCH 3/7] Allow explicit links to sibling GitHub Pages projects --- tests/python/test_generated_pages.py | 24 ++++++++++++++++++++++++ tools/check_generated_pages.py | 4 ++++ 2 files changed, 28 insertions(+) diff --git a/tests/python/test_generated_pages.py b/tests/python/test_generated_pages.py index ef53891..ee57579 100644 --- a/tests/python/test_generated_pages.py +++ b/tests/python/test_generated_pages.py @@ -148,6 +148,30 @@ def test_accepts_all_routes_and_resolved_targets(self) -> None: self.assertEqual(result.errors, ()) self.assertEqual(result.page_count, 41) + def test_sibling_project_links_and_internal_link_validation(self) -> None: + cases = ( + (f"{SITE_URL}/wang-studio/", None), + ("//xtraid.github.io/wang-studio/", None), + ("/wang-studio/", "internal URL escapes baseurl"), + (f"{SITE_URL}{BASEURL}/missing/", "unresolved href target"), + ) + for target, expected_error in cases: + with self.subTest(target=target), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + _valid_site(root) + home = root / "index.html" + home.write_text( + home.read_text(encoding="utf-8").replace( + "", f'Project' + ), + encoding="utf-8", + ) + result = check_site(root, site_url=SITE_URL, baseurl=BASEURL) + if expected_error is None: + self.assertEqual(result.errors, ()) + else: + self.assertIn(expected_error, "\n".join(result.errors)) + def test_rejects_the_known_home_regression_and_broken_links(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tools/check_generated_pages.py b/tools/check_generated_pages.py index 97a656b..0531a15 100644 --- a/tools/check_generated_pages.py +++ b/tools/check_generated_pages.py @@ -380,6 +380,10 @@ def _local_target( elif baseurl and path.startswith(f"{baseurl}/"): path = path[len(baseurl) :] elif baseurl: + # Absolute URLs can intentionally link to another project on this host. + # Relative links must still stay inside this project base URL. + if urlsplit(value).netloc: + return None raise ValueError(f"internal URL escapes baseurl {baseurl!r}") trailing_slash = path.endswith("/") From 96c6e708aa0d13e0cf91106a86c952efd8757d9d Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Sat, 10 Oct 2026 03:22:59 +0200 Subject: [PATCH 4/7] Feature the browser editor on the project homepage --- README.md | 6 ++++++ docs/assets/css/site.css | 23 +++++++++++++++++++++++ docs/index.md | 8 ++++++++ 3 files changed, 37 insertions(+) diff --git a/README.md b/README.md index 42baf52..dba5a7b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ it, and checking that the different views of the instance agree. [Evidence](https://xtraid.github.io/tiling-foundry/evidence/) · [Run dossiers](https://xtraid.github.io/tiling-foundry/run-dossiers/) +**[Open Wang Studio in your browser →](https://xtraid.github.io/wang-studio/)** + +Build your own regions and tilesets in the independent research editor. +Both C solvers run in your browser; no download or installation is required. +Square solving is available now. + ## Why this repository exists Yang and Zhang proved in 2024 that tiling finite simply connected regions is diff --git a/docs/assets/css/site.css b/docs/assets/css/site.css index 10ffba3..0703ab8 100644 --- a/docs/assets/css/site.css +++ b/docs/assets/css/site.css @@ -1307,3 +1307,26 @@ img { color: #161819 !important; } } + +/* Keep the browser app visible near the top of the project home. */ +.studio-entry { + margin-block: 2rem; + padding: 1.25rem; + border: 1px solid var(--rule-strong); + background: var(--black-raised); +} + +.studio-entry h2 { margin-top: 0; } +.studio-entry__link { + display: inline-block; + padding: 0.75rem 1rem; + border: 1px solid currentColor; + color: var(--ink-strong); + font-weight: 700; +} +.studio-entry__link:hover, +.studio-entry__link:focus-visible { + text-decoration-thickness: 0.15em; + outline: 2px solid currentColor; + outline-offset: 3px; +} diff --git a/docs/index.md b/docs/index.md index 840d170..e5948c2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,6 +20,14 @@ description: From a CM1-in-3 formula to a region over 23 Wang tiles, checked sol checkers validate SAT witnesses, and a PDF dossier records the run.

+ +
Run the demo Follow the technical tour From bf07e7e7eb97fed4f5eeb05ce6f7dd9e2491517a Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Sun, 11 Oct 2026 10:12:08 +0200 Subject: [PATCH 5/7] Support cell-side graph enumeration in the shared C solver --- include/wang/solver.h | 18 +++++ packages/solver-web/README.md | 14 +++- packages/solver-web/bridge.c | 14 ++++ packages/solver-web/client.mjs | 4 +- packages/solver-web/prepare.mjs | 54 +++++++++---- packages/solver-web/worker.mjs | 8 +- src/solver/solver_serial.c | 131 +++++++++++++++++++++++++++----- tests/c/test_solver_graph.c | 48 ++++++++++++ tests/test_solver_web.mjs | 58 ++++++++++++-- tools/build_solver_web.py | 4 +- 10 files changed, 302 insertions(+), 51 deletions(-) create mode 100644 tests/c/test_solver_graph.c diff --git a/include/wang/solver.h b/include/wang/solver.h index 78cc1ad..1c68506 100644 --- a/include/wang/solver.h +++ b/include/wang/solver.h @@ -171,6 +171,24 @@ int wang_enumerate_serial_tileset( void *userdata, WangSolverMetrics *metrics ); +/* Explicit reciprocal cell/side adjacency. -1 is an exterior side; otherwise + * neighbors[i * side_count + d] is the neighboring cell, and neighbor_sides + * gives its incident side. Dense edges are tile-major; boundary uses COLOR_NONE. + * Borrowed inputs, 1..6 sides, 1..4096 cells, 1..32 tiles. No rotations. + * Shares propagation, MRV, trail and DFS with square enumeration. */ +#define WANG_GRAPH_MAX_SIDES 6u +typedef struct { + size_t cell_count; + size_t side_count; + const int32_t *neighbors; + const uint8_t *neighbor_sides; + const uint8_t *boundary; + const uint8_t *edges; +} WangGraph; +int wang_enumerate_graph(const WangGraph *graph, size_t tile_count, + const WangSolverOptions *options, bool reference, WangSolutionVisitor visitor, + void *userdata, WangSolverMetrics *metrics); + /* Release the owned snapshot and reset every field. Accepts NULL. */ void wang_solve_result_destroy(WangSolveResult *result); diff --git a/packages/solver-web/README.md b/packages/solver-web/README.md index 1d7270f..c7f7ac4 100644 --- a/packages/solver-web/README.md +++ b/packages/solver-web/README.md @@ -43,8 +43,9 @@ One live search per Solver instance. Statuses: `running`, `paused`, `complete`, decimal strings too. Duplicate edge tuples are counted once, subject to initial domains. Palette indices are symbolic identities, even when RGB values repeat. +Supported geometries: `square`, `tri-up`, `tri-side`, `hex-flat`, `hex-point`. Square edges are `(N,E,S,W)`, with fixed orientations. Limits are 32 tiles, -16 used colors, 4096 active cells, 16384 bounding-box cells, and 128 palette +16 used colors, 4096 active cells, 16384 bounding-box cells for squares, and 128 palette entries. `fixed` optionally pins an exact edge tuple. `required` constrains each side; `null` means unconstrained. Negative coordinates, holes and disconnected inputs are supported, but applications may impose stricter research profiles. @@ -71,9 +72,14 @@ Stop/timeout terminates the Worker and retains the last published lower bound. Browser timer throttling can delay enforcement in background tabs. A closed page ends its computations; there is no server-side persistence. -This first package exposes only runtime square enumeration. Explicit graph -adjacency, additional geometries and generalized-piece constraints are future -engine features, not inferred from the editor's drawing capabilities. +Triangles use IDs `i,j,half` (half 0 or 1) and three sides; hexagons use IDs +`i,j` and six sides. Edge order follows Studio's polygon vertex order. Triangle +neighbors share the same side index (including inverted cells); hexagonal +neighbors use `(side+3)%6`. Variants rotate the lattice, without implicit tile +rotations. The input adapter builds reciprocal cell/side arcs and calls +`wang_enumerate_graph`, sharing the existing C propagation, MRV, trail and DFS. +Graph regions use dense active cells, without the square bounding-box limit. +Generalized-piece constraints are not supported. Consumers should retain `manifest.json` with experiment results, pin releases, and update only between searches. Integrity hashes identify artifacts; a hash diff --git a/packages/solver-web/bridge.c b/packages/solver-web/bridge.c index 314ae34..f82c4f8 100644 --- a/packages/solver-web/bridge.c +++ b/packages/solver-web/bridge.c @@ -53,3 +53,17 @@ int foundry_enumerate(int32_t width, int32_t height, const uint8_t *active, region_destroy(®ion); return result; } + +EMSCRIPTEN_KEEPALIVE +int foundry_enumerate_graph(int32_t count, int32_t sides, const int32_t *neighbors, + const uint8_t *neighbor_sides, const uint8_t *boundary, const uint8_t *edges, + size_t tile_count, const uint32_t *initial, int reference) +{ + if (count <= 0 || sides <= 0 || !initial) return -1; + const WangGraph graph = {(size_t)count, (size_t)sides, neighbors, neighbor_sides, boundary, edges}; + const WangSolverOptions options = { + .flags = WANG_SOLVE_COLLECT_METRICS, + .initial_domains = initial, .initial_domain_count = (size_t)count + }; + return wang_enumerate_graph(&graph, tile_count, &options, reference != 0, visit, NULL, NULL); +} diff --git a/packages/solver-web/client.mjs b/packages/solver-web/client.mjs index 5993d85..b2b3976 100644 --- a/packages/solver-web/client.mjs +++ b/packages/solver-web/client.mjs @@ -1,4 +1,4 @@ -import {prepare, limits} from './prepare.mjs'; +import {prepare, limits, geometries} from './prepare.mjs'; const terminal = new Set(['complete', 'cancelled', 'timeout', 'expired', 'error']); let manifestPromise; @@ -9,7 +9,7 @@ export async function capabilities() { if (manifest.api_version !== 1) throw Error('Versione del pacchetto solver non compatibile.'); return manifest; }).catch(error => { manifestPromise = null; throw error; }); - return {solver: 'optimized', solvers: ['optimized', 'reference'], runtime: 'wasm', geometries: ['square'], + return {solver: 'optimized', solvers: ['optimized', 'reference'], runtime: 'wasm', geometries, enumeration: true, duplicate_tiles: 'collapsed', ...limits, engine: await manifestPromise}; } diff --git a/packages/solver-web/prepare.mjs b/packages/solver-web/prepare.mjs index 40fcbb3..65156af 100644 --- a/packages/solver-web/prepare.mjs +++ b/packages/solver-web/prepare.mjs @@ -3,17 +3,36 @@ export const limits = Object.freeze({ max_tiles: 32, max_used_colors: 16, max_cells: 4096, max_area: 16384, max_palette: 128, default_timeout_seconds: 30, }); +export const geometries = ['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point']; const offsets = [[0, -1], [1, 0], [0, 1], [-1, 0]]; +// Lattice coordinates are an input adapter only. The C core receives cell/side arcs. +export function adjacent(geometry, id) { + const [x, y, half] = id.split(',').map(Number); + if (geometry === 'square') return offsets.map(([dx,dy],d) => [`${x+dx},${y+dy}`, (d+2)%4]); + if (geometry.startsWith('tri')) return [ + [`${x},${y+(half ? 1 : -1)},${1-half}`,0], + [`${x},${y},${1-half}`,1], + [`${x+(half ? 1 : -1)},${y},${1-half}`,2], + ]; + const steps = geometry === 'hex-flat' + ? [[1,0],[0,1],[-1,1],[-1,0],[0,-1],[1,-1]] + : [[1,-1],[1,0],[0,1],[-1,1],[-1,0],[0,-1]]; + return steps.map(([dx,dy],d) => [`${x+dx},${y+dy}`, (d+3)%6]); +} + export function prepare(data) { - if (!data || data.geometry !== 'square') throw Error('Il solver supporta per ora i quadrati.'); + if (!data || !geometries.includes(data.geometry)) throw Error('Geometria non supportata.'); + const graph = data.geometry !== 'square'; + const triangle = data.geometry.startsWith('tri'); + const sides = triangle ? 3 : data.geometry.startsWith('hex') ? 6 : 4; const {palette, tiles, cells} = data; if (!Array.isArray(palette) || !palette.length || palette.length > limits.max_palette || !palette.every(c => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c))) { throw Error('Palette non valida.'); } function edges(value, nullable = false) { - if (!Array.isArray(value) || value.length !== 4 || !value.every(v => + if (!Array.isArray(value) || value.length !== sides || !value.every(v => (nullable && v === null) || (Number.isInteger(v) && v >= 0 && v < palette.length))) { throw Error('Una tessera o un vincolo contiene lati non validi.'); } @@ -38,12 +57,12 @@ export function prepare(data) { const used = new Set(tiles.flat()); let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; for (const cell of cells) { - if (!cell || typeof cell.id !== 'string' || !/^-?\d{1,7},-?\d{1,7}$/.test(cell.id)) { + if (!cell || typeof cell.id !== 'string' || !(triangle ? /^-?\d{1,7},-?\d{1,7},[01]$/ : /^-?\d{1,7},-?\d{1,7}$/).test(cell.id)) { throw Error('Coordinate non valide.'); } const [x, y] = cell.id.split(',').map(Number); - const key = `${x},${y}`; - if (Math.abs(x) > 1000000 || Math.abs(y) > 1000000 || records.has(key)) { + const key = triangle ? `${x},${y},${cell.id.split(',')[2]}` : `${x},${y}`; + if (Math.abs(x) > 1000000 || Math.abs(y) > 1000000 || cell.id !== key || records.has(key)) { throw Error('Coordinate duplicate o fuori intervallo.'); } edges(cell.required, true); @@ -54,15 +73,18 @@ export function prepare(data) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); } - const width = x1 - x0 + 1, height = y1 - y0 + 1, area = width * height; + const width = x1 - x0 + 1, height = y1 - y0 + 1, area = graph ? cells.length : width * height; if (area > limits.max_area) throw Error('Il rettangolo della regione supera 16384 celle.'); if (used.size > limits.max_used_colors) throw Error('Il solver supporta al massimo 16 colori usati.'); // Colors are symbolic palette indices. Equal RGB values remain distinct. const remap = new Map([...used].sort((a, b) => a - b).map((v, i) => [v, i])); - const active = new Uint8Array(area), boundary = new Uint8Array(area * 4).fill(255); + const active = new Uint8Array(area), boundary = new Uint8Array(area * sides).fill(255); const domains = new Uint32Array(area), indices = new Map(); + const neighbors = new Int32Array(area * sides).fill(-1); + const neighborSides = new Uint8Array(area * sides); + for (const cell of records.values()) indices.set(cell.id, graph ? indices.size : (cell.y-y0)*width+cell.x-x0); for (const cell of records.values()) { - const index = (cell.y - y0) * width + cell.x - x0; + const index = indices.get(cell.id); indices.set(cell.id, index); active[index] = 1; tiles.forEach((tile, t) => { @@ -71,13 +93,15 @@ export function prepare(data) { domains[index] = (domains[index] | (1 << t)) >>> 0; } }); - offsets.forEach(([dx, dy], d) => { - if (!records.has(`${cell.x + dx},${cell.y + dy}`) && cell.required[d] !== null) { - boundary[index * 4 + d] = remap.get(cell.required[d]); + adjacent(data.geometry, cell.id).forEach(([id, side], d) => { + neighbors[index * sides + d] = indices.get(id) ?? -1; + neighborSides[index * sides + d] = side; + if (cell.required[d] !== null && (graph || !records.has(id))) { + boundary[index * sides + d] = remap.get(cell.required[d]); } }); } - return {width, height, active, boundary, domains, indices, records, tiles, + return {geometry: data.geometry, graph, sides, neighbors, neighborSides, width, height, active, boundary, domains, indices, records, tiles, edges: new Uint8Array(tiles.flat().map(v => remap.get(v))), timeout, mode, solver}; } @@ -89,10 +113,10 @@ export function checkAssignments(prepared, assignments) { (cell.fixed && tile.some((v, d) => v !== cell.fixed[d]))) { throw Error('Soluzione non valida o tessera fissata modificata.'); } - offsets.forEach(([dx, dy], d) => { - const other = prepared.records.get(`${cell.x + dx},${cell.y + dy}`); + adjacent(prepared.geometry, cell.id).forEach(([id, side], d) => { + const other = prepared.records.get(id); if ((cell.required[d] !== null && tile[d] !== cell.required[d]) || - (other && tile[d] !== prepared.tiles[assignments[other.id]]?.[(d + 2) % 4])) { + (other && tile[d] !== prepared.tiles[assignments[other.id]]?.[side])) { throw Error('La soluzione contiene lati incompatibili.'); } }); diff --git a/packages/solver-web/worker.mjs b/packages/solver-web/worker.mjs index e9c3d51..383eb24 100644 --- a/packages/solver-web/worker.mjs +++ b/packages/solver-web/worker.mjs @@ -72,11 +72,13 @@ async function run(payload) { }; publish('running'); try { - const args = [prepared.width, prepared.height, allocate(prepared.active), + const args = prepared.graph ? [prepared.records.size, prepared.sides, allocate(prepared.neighbors), + allocate(prepared.neighborSides), allocate(prepared.boundary), allocate(prepared.edges), + prepared.tiles.length, allocate(prepared.domains), Number(prepared.solver === 'reference')] : [prepared.width, prepared.height, allocate(prepared.active), allocate(prepared.boundary), allocate(prepared.edges), prepared.tiles.length, allocate(prepared.domains), Number(prepared.solver === 'reference')]; - const status = await module.ccall('foundry_enumerate', 'number', - Array(8).fill('number'), args, {async: true}); + const status = await module.ccall(prepared.graph ? 'foundry_enumerate_graph' : 'foundry_enumerate', 'number', + Array(args.length).fill('number'), args, {async: true}); if (status !== 0) throw Error('Il motore ha interrotto la ricerca senza esaurirla.'); publish('complete', true); } finally { diff --git a/src/solver/solver_serial.c b/src/solver/solver_serial.c index 073b33e..a76f002 100644 --- a/src/solver/solver_serial.c +++ b/src/solver/solver_serial.c @@ -25,8 +25,8 @@ enum { }; typedef struct { - uint32_t edge_mask[DIR_COUNT][COLOR_COUNT]; - uint32_t compat[DIR_COUNT][WANG_RUNTIME_MAX_TILES]; + uint32_t edge_mask[WANG_GRAPH_MAX_SIDES][COLOR_COUNT]; + uint32_t compat[WANG_GRAPH_MAX_SIDES * WANG_GRAPH_MAX_SIDES][WANG_RUNTIME_MAX_TILES]; } SolverTables; typedef struct { @@ -76,6 +76,7 @@ typedef struct { typedef struct { const Region *region; + const WangGraph *graph; Enumeration *enumeration; SolverTables tables; uint32_t *byte_support; @@ -823,6 +824,7 @@ static size_t neighbor_index( Dir dir ) { + if (state->graph) return (size_t)state->graph->neighbors[cell_index * state->graph->side_count + dir]; const size_t width = (size_t)state->region->width; switch (dir) { case N: @@ -841,7 +843,7 @@ static size_t neighbor_index( static uint32_t supported_neighbor_domain( SolverState *state, - Dir dir, + size_t dir, uint32_t domain ) { @@ -893,7 +895,7 @@ static PropagateStatus propagate_queue( return PROPAGATE_CONFLICT; } - for (Dir dir = N; dir < DIR_COUNT; ++dir) { + for (size_t dir = 0; dir < (state->graph ? state->graph->side_count : DIR_COUNT); ++dir) { if ((state->neighbor_mask[cell_index] & (uint8_t)(UINT8_C(1) << dir)) == 0) { continue; @@ -905,7 +907,8 @@ static PropagateStatus propagate_queue( const uint32_t supported = supported_neighbor_domain( state, - dir, + state->graph ? dir * state->graph->side_count + + state->graph->neighbor_sides[cell_index * state->graph->side_count + dir] : dir, domain ); @@ -1488,7 +1491,17 @@ static bool initialize_domains( ++state->metrics.domain_reductions; } - for (Dir dir = N; dir < DIR_COUNT; ++dir) { + for (size_t dir = 0; dir < (state->graph ? state->graph->side_count : DIR_COUNT); ++dir) { + if (state->graph) { + const size_t side = index * state->graph->side_count + dir; + if (state->graph->neighbors[side] >= 0) { + state->neighbor_mask[index] |= (uint8_t)(1u << dir); + state->has_neighbor_arcs = true; + } + const uint8_t color = state->graph->boundary[side]; + if (color != COLOR_NONE) domain &= state->tables.edge_mask[dir][color]; + continue; + } const RegionCell *neighbor = region_cell_const( state->region, x + dx[dir], @@ -1521,8 +1534,12 @@ static bool initialize_domains( bool duplicate = false; for (size_t u = 0; u < t; ++u) { if ((unique & (UINT32_C(1) << u)) != 0 && - memcmp(state->tileset[t].edge, state->tileset[u].edge, - sizeof(state->tileset[t].edge)) == 0) { + (state->graph + ? memcmp(state->graph->edges + t * state->graph->side_count, + state->graph->edges + u * state->graph->side_count, + state->graph->side_count) + : memcmp(state->tileset[t].edge, state->tileset[u].edge, + sizeof(state->tileset[t].edge))) == 0) { duplicate = true; break; } @@ -1554,6 +1571,25 @@ static bool initialize_domains( static bool verify_sat_domains(const SolverState *state) { + if (state->graph) { + const WangGraph *g = state->graph; + for (size_t i = 0; i < g->cell_count; ++i) { + if (!domain_is_singleton(state->domains[i])) return false; + const size_t t = first_set_tile(state->domains[i]); + for (size_t d = 0; d < g->side_count; ++d) { + const size_t side = i * g->side_count + d; + const uint8_t color = g->edges[t * g->side_count + d]; + if (g->boundary[side] != COLOR_NONE && g->boundary[side] != color) return false; + const int32_t n = g->neighbors[side]; + if (n >= 0) { + if (!domain_is_singleton(state->domains[n])) return false; + const size_t u = first_set_tile(state->domains[n]); + if (color != g->edges[u * g->side_count + g->neighbor_sides[side]]) return false; + } + } + } + return true; + } size_t bytes; if (!checked_mul_size(state->cell_count, sizeof(TileId), &bytes)) { return false; @@ -1653,7 +1689,8 @@ static WangSolveStatus solve_wang_core( WangSolveTrace *out_trace, const WangTile *tileset, size_t tileset_count, - Enumeration *enumeration + Enumeration *enumeration, + const WangGraph *graph ) { if (!result_is_destroyed(out_result) || @@ -1684,6 +1721,7 @@ static WangSolveStatus solve_wang_core( SolverState state = {0}; state.writer.fd = -1; state.region = region; + state.graph = graph; state.enumeration = enumeration; state.tileset = tileset; state.tileset_count = tileset_count; @@ -1701,8 +1739,20 @@ static WangSolveStatus solve_wang_core( if (!solver_tables_are_valid(&state.tables, tileset, tileset_count)) { return WANG_SOLVE_ERROR; } + const size_t compatibility_count = graph ? graph->side_count * graph->side_count : DIR_COUNT; + if (graph) { + memset(&state.tables, 0, sizeof(state.tables)); + for (size_t t = 0; t < tileset_count; ++t) + for (size_t d = 0; d < graph->side_count; ++d) + state.tables.edge_mask[d][graph->edges[t * graph->side_count + d]] |= UINT32_C(1) << t; + for (size_t d = 0; d < graph->side_count; ++d) + for (size_t e = 0; e < graph->side_count; ++e) + for (size_t t = 0; t < tileset_count; ++t) + state.tables.compat[d * graph->side_count + e][t] = + state.tables.edge_mask[e][graph->edges[t * graph->side_count + d]]; + } if (mechanisms.use_bytewise_support) { - const size_t entries = DIR_COUNT * state.domain_byte_count * + const size_t entries = compatibility_count * state.domain_byte_count * WANG_SUPPORT_BYTE_VALUE_COUNT; state.byte_support = calloc(entries, sizeof(*state.byte_support)); if (state.byte_support == NULL) { @@ -1711,7 +1761,7 @@ static WangSolveStatus solve_wang_core( } /* Same bytewise union optimization; runtime tile count, no copies of * the search implementation and no canonical tileset dependency. */ - for (Dir d = N; d < DIR_COUNT; ++d) { + for (size_t d = 0; d < compatibility_count; ++d) { for (size_t byte = 0; byte < state.domain_byte_count; ++byte) { uint32_t *support = state.byte_support + (d * state.domain_byte_count + byte) * 256u; @@ -1979,7 +2029,7 @@ WangSolveStatus wang_solve_serial( }, NULL, NULL, - TILESET, TILE_COUNT, NULL + TILESET, TILE_COUNT, NULL, NULL ); } @@ -2003,7 +2053,7 @@ WangSolveStatus wang_solve_optimized( }, NULL, NULL, - TILESET, TILE_COUNT, NULL + TILESET, TILE_COUNT, NULL, NULL ); } @@ -2038,7 +2088,7 @@ WangSolveStatus wang_solve_serial_traced( }, trace_options, &out_result->trace, - TILESET, TILE_COUNT, NULL + TILESET, TILE_COUNT, NULL, NULL ); } @@ -2066,7 +2116,7 @@ WangSolveStatus wang_solve_optimized_traced( }, trace_options, &out_result->trace, - TILESET, TILE_COUNT, NULL + TILESET, TILE_COUNT, NULL, NULL ); } @@ -2089,7 +2139,7 @@ WangSolveStatus wang_solve_optimized_tileset( .deduplicate_queue = WANG_OPTIMIZED_QUEUE_DEDUP != 0, .index_mrv = WANG_OPTIMIZED_MRV_INDEX != 0, }, - NULL, NULL, tileset, tileset_count, NULL + NULL, NULL, tileset, tileset_count, NULL, NULL ); } @@ -2113,7 +2163,7 @@ int wang_enumerate_optimized_tileset( .use_bytewise_support = true, .deduplicate_queue = WANG_OPTIMIZED_QUEUE_DEDUP != 0, .index_mrv = WANG_OPTIMIZED_MRV_INDEX != 0, - }, NULL, NULL, tileset, tileset_count, &enumeration); + }, NULL, NULL, tileset, tileset_count, &enumeration, NULL); } int wang_enumerate_serial_tileset( @@ -2136,5 +2186,50 @@ int wang_enumerate_serial_tileset( .use_bytewise_support = false, .deduplicate_queue = false, .index_mrv = false, - }, NULL, NULL, tileset, tileset_count, &enumeration); + }, NULL, NULL, tileset, tileset_count, &enumeration, NULL); +} + +int wang_enumerate_graph(const WangGraph *g, size_t tile_count, + const WangSolverOptions *options, bool reference, WangSolutionVisitor visitor, + void *userdata, WangSolverMetrics *metrics) +{ + if (metrics) *metrics = (WangSolverMetrics){0}; + if (!g || !visitor || !g->cell_count || g->cell_count > 4096 || + !g->side_count || g->side_count > WANG_GRAPH_MAX_SIDES || + !tile_count || tile_count > WANG_RUNTIME_MAX_TILES || + !g->neighbors || !g->neighbor_sides || !g->edges || !g->boundary || + (options && (options->flags & ~WANG_SOLVE_COLLECT_METRICS))) return -1; + for (size_t t = 0; t < tile_count * g->side_count; ++t) + if (g->edges[t] >= COLOR_COUNT) return -1; + for (size_t i = 0; i < g->cell_count; ++i) { + for (size_t d = 0; d < g->side_count; ++d) { + const size_t side = i * g->side_count + d; + const int32_t n = g->neighbors[side]; + if (g->boundary[side] != COLOR_NONE && g->boundary[side] >= COLOR_COUNT) return -1; + if (n == -1) continue; + if (n < 0 || (size_t)n >= g->cell_count || (size_t)n == i || + g->neighbor_sides[side] >= g->side_count) return -1; + const size_t back = (size_t)n * g->side_count + g->neighbor_sides[side]; + if (g->neighbors[back] != (int32_t)i || g->neighbor_sides[back] != d) return -1; + } + } + /* The dense carrier supplies active-cell bookkeeping only. Geometry and + * verification use the explicit graph; square entry points are unchanged. */ + Region region = {0}; + if (!region_init(®ion, (int32_t)g->cell_count, 1)) return -1; + for (size_t i = 0; i < g->cell_count; ++i) region.cells[i].active = true; + WangTile carrier[WANG_RUNTIME_MAX_TILES] = {0}; + Enumeration enumeration = {visitor, userdata, metrics}; + WangSolveResult unused = {0}; + const int result = (int)solve_wang_core(®ion, options, &unused, + (SolverMechanisms){ + .stack_mode = reference ? SEARCH_STACK_FIXED : SEARCH_STACK_DYNAMIC, + .record_initial_trail = reference, + .transfer_sat_domains = !reference, + .use_bytewise_support = !reference, + .deduplicate_queue = !reference && WANG_OPTIMIZED_QUEUE_DEDUP != 0, + .index_mrv = !reference && WANG_OPTIMIZED_MRV_INDEX != 0, + }, NULL, NULL, carrier, tile_count, &enumeration, g); + region_destroy(®ion); + return result; } diff --git a/tests/c/test_solver_graph.c b/tests/c/test_solver_graph.c new file mode 100644 index 0000000..ecced32 --- /dev/null +++ b/tests/c/test_solver_graph.c @@ -0,0 +1,48 @@ +#include "wang/solver.h" +#include +#include + +static bool visit(const uint32_t *domains, size_t n, + const WangSolverMetrics *metrics, void *userdata) +{ + (void)metrics; + assert(n == 2 && domains[0] && domains[1]); + ++*(int *)userdata; + return true; +} + +int main(void) +{ + /* Different side numbers exercise explicit arcs, independent of geometry. */ + int32_t neighbors[12] = {-1,-1,-1,-1,-1,1, 0,-1,-1,-1,-1,-1}; + uint8_t sides[12] = {0,0,0,0,0,0, 5,0,0,0,0,0}; + uint8_t boundary[12]; + for (size_t i = 0; i < 12; ++i) boundary[i] = COLOR_NONE; + const uint8_t edges[18] = {0,0,0,0,0,0, 1,1,1,1,1,1, 0,0,0,0,0,0}; + WangGraph graph = {2,6,neighbors,sides,boundary,edges}; + for (int reference = 0; reference < 2; ++reference) { + int count = 0; + assert(wang_enumerate_graph(&graph,3,NULL,reference,visit,&count,NULL) == 0); + assert(count == 2); /* Duplicate tuples collapse. */ + uint32_t domains[2] = {4,1}; + WangSolverOptions options = {.initial_domains=domains,.initial_domain_count=2}; + count = 0; + assert(wang_enumerate_graph(&graph,3,&options,reference,visit,&count,NULL) == 0); + assert(count == 1); /* A duplicate pinned by ID remains available. */ + boundary[5] = 1; + count = 0; + assert(wang_enumerate_graph(&graph,3,&options,reference,visit,&count,NULL) == 0); + assert(count == 0); + boundary[5] = COLOR_NONE; + } + int count = 0; + sides[6] = 4; + assert(wang_enumerate_graph(&graph,3,NULL,false,visit,&count,NULL) == -1); + sides[6] = 5; + neighbors[5] = 2; + assert(wang_enumerate_graph(&graph,3,NULL,false,visit,&count,NULL) == -1); + neighbors[5] = 1; + graph.side_count = 7; + assert(wang_enumerate_graph(&graph,3,NULL,false,visit,&count,NULL) == -1); + puts("test_solver_graph: OK"); +} diff --git a/tests/test_solver_web.mjs b/tests/test_solver_web.mjs index 3a05d10..fe40ea1 100644 --- a/tests/test_solver_web.mjs +++ b/tests/test_solver_web.mjs @@ -6,10 +6,27 @@ const root = resolve(process.argv[2] || 'build/solver-web/package'); const {default: createModule} = await import(pathToFileURL(root + '/foundry.mjs')); const {prepare, checkAssignments} = await import(pathToFileURL(root + '/prepare.mjs')); const module = await createModule(); -const offsets = [[0,-1],[1,0],[0,1],[-1,0]]; +// Independent polygon edge matching, not the package's lattice-neighbor table. +function polygon(data, id) { + const [i,j,h=0]=id.split(',').map(Number), g=data.geometry; + if(g==='square') return [[i,j],[i+1,j],[i+1,j+1],[i,j+1]]; + if(g.startsWith('tri')) { + const x=i+j/2,y=j*Math.sqrt(3)/2; + const p=h?[[x+1.5,y+Math.sqrt(3)/2],[x+.5,y+Math.sqrt(3)/2],[x+1,y]] + :[[x,y],[x+1,y],[x+.5,y+Math.sqrt(3)/2]]; + return g==='tri-side'?p.map(([x,y])=>[-y,x]):p; + } + const flat=g==='hex-flat',x=flat?1.5*i:Math.sqrt(3)*(i+j/2),y=flat?Math.sqrt(3)*(j+i/2):1.5*j; + return Array.from({length:6},(_,d)=>[x+Math.cos((flat?0:-Math.PI/2)+d*Math.PI/3),y+Math.sin((flat?0:-Math.PI/2)+d*Math.PI/3)]); +} +function edgeKeys(data,id) { + const p=polygon(data,id).map(p=>p.map(x=>Math.round(x*1e5)).join(',')); + return p.map((v,d)=>[v,p[(d+1)%p.length]].sort().join('|')); +} function brute(data) { const results = new Set(), placed = {}; + const keys = Object.fromEntries(data.cells.map(c=>[c.id,edgeKeys(data,c.id)])); const visit = index => { if (index === data.cells.length) { results.add(JSON.stringify(data.cells.map(c => placed[c.id]))); @@ -19,10 +36,9 @@ function brute(data) { for (const tile of data.tiles) { if (cell.fixed && tile.some((v,d) => v !== cell.fixed[d])) continue; if (cell.required.some((v,d) => v !== null && tile[d] !== v)) continue; - if (offsets.some(([dx,dy],d) => { - const other = placed[`${x+dx},${y+dy}`]; - return other && tile[d] !== other[(d+2)%4]; - })) continue; + if (Object.entries(placed).some(([id,other])=>keys[cell.id].some((key,d)=>{ + const side=keys[id].indexOf(key);return side>=0 && tile[d]!==other[side]; + }))) continue; placed[cell.id] = tile; visit(index+1); delete placed[cell.id]; @@ -49,10 +65,12 @@ async function enumerate(data, pause) { }; module.onWait = async () => { await new Promise(r => setTimeout(r,5)); return 1; }; try { - const args = [prepared.width,prepared.height,allocate(prepared.active), + const args = prepared.graph ? [prepared.records.size,prepared.sides,allocate(prepared.neighbors), + allocate(prepared.neighborSides),allocate(prepared.boundary),allocate(prepared.edges), + prepared.tiles.length,allocate(prepared.domains),Number(prepared.solver==='reference')] : [prepared.width,prepared.height,allocate(prepared.active), allocate(prepared.boundary),allocate(prepared.edges),prepared.tiles.length, allocate(prepared.domains),Number(prepared.solver==='reference')]; - assert.equal(await module.ccall('foundry_enumerate','number',Array(8).fill('number'),args,{async:true}),0); + assert.equal(await module.ccall(prepared.graph?'foundry_enumerate_graph':'foundry_enumerate','number',Array(args.length).fill('number'),args,{async:true}),0); assert.equal(new Set(results).size,results.length,'No repeated edge configurations'); return results.sort(); } finally { pointers.forEach(p => module._free(p)); } @@ -89,3 +107,29 @@ const duplicate=instance([[0,0,0,0]],1,1); duplicate.cells.push({id:'00,0',required:[null,null,null,null]}); assert.throws(()=>prepare(duplicate),/duplicate/); console.log('PASS: C reference and optimized Wasm vs exhaustive enumeration on 100 instances, async pause, duplicates, disconnected regions, holes, bit 31, validation.'); + +for(const geometry of ['tri-up','tri-side','hex-flat','hex-point']) { + const sides=geometry.startsWith('tri')?3:6; + const ids=sides===3?['-1,0,0','-1,0,1','0,0,0','0,0,1','0,-1,1']:['-1,0','0,0','0,1','1,0']; + const base={geometry,palette:['#ffffff','#000000'],tiles:[Array(sides).fill(0),Array(sides).fill(1),Array(sides).fill(0)], + cells:ids.map(id=>({id,required:Array(sides).fill(null)}))}; + for(const solver of ['optimized','reference']) { + assert.deepEqual(await enumerate({...base,solver},true),brute(base)); + const disconnected={...base,solver,cells:[base.cells[0],{id:sides===3?'9,9,1':'9,9',required:Array(sides).fill(null)}]}; + assert.equal((await enumerate(disconnected)).length,4); + const bit31={...base,solver,palette:Array(16).fill('#ffffff'), + tiles:Array.from({length:32},(_,n)=>[n%16,Math.floor(n/16),...Array(sides-2).fill(0)]),cells:[base.cells[0]]}; + bit31.cells=[{...base.cells[0],fixed:bit31.tiles[31]}]; + assert.deepEqual(await enumerate(bit31),brute(bit31)); + } + for(let trial=0;trial<100;trial++) { + const data={...base,tiles:Array.from({length:1+random(5)},()=>Array.from({length:sides},()=>random(2))), + cells:base.cells.map(c=>({...c,required:Array.from({length:sides},()=>random(5)===0?random(2):null)}))}; + if(trial%3===0)data.cells[0].fixed=data.tiles[random(data.tiles.length)]; + const expected=brute(data); + for(const solver of ['optimized','reference'])assert.deepEqual(await enumerate({...data,solver}),expected,geometry+' trial '+trial); + } + assert.throws(()=>prepare({...base,cells:[{...base.cells[0],id:'0,0,2'}]})); + assert.throws(()=>checkAssignments(prepare(base),{})); +} +console.log('PASS: all four triangular/hexagonal variants, 400 random instances against independent polygon oracle, both C engines, pause, fixed tiles, duplicates, disconnected cells and bit 31.'); diff --git a/tools/build_solver_web.py b/tools/build_solver_web.py index 5bf1653..82d3003 100644 --- a/tools/build_solver_web.py +++ b/tools/build_solver_web.py @@ -23,7 +23,7 @@ '-sASYNCIFY=1', '-sASYNCIFY_STACK_SIZE=65536', '-sALLOW_MEMORY_GROWTH=1', '-sMAXIMUM_MEMORY=268435456', '-sSTACK_SIZE=1048576', '-sFILESYSTEM=0', - '-sEXPORTED_FUNCTIONS=["_foundry_enumerate","_malloc","_free"]', + '-sEXPORTED_FUNCTIONS=["_foundry_enumerate","_foundry_enumerate_graph","_malloc","_free"]', '-sEXPORTED_RUNTIME_METHODS=["ccall","HEAPU8","HEAPU32"]', ] @@ -65,7 +65,7 @@ def main(): 'README.md', 'LICENSE', 'package.json'] manifest = dict( name=descriptor['name'], version=version, api_version=1, solvers=['optimized', 'reference'], - runtime='wasm', geometries=['square'], + runtime='wasm', geometries=['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point'], source=dict(repository='https://github.com/xtraid/tiling-foundry', revision=revision, dirty=dirty, sha256=source_hash, files=source_files), build=dict(compiler=compiler, flags=FLAGS), From 84ba5b0606f3aeb61f3448acecaa2249f0f09f9e Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Mon, 12 Oct 2026 10:54:58 +0200 Subject: [PATCH 6/7] Support runtime-sized domains in the browser solver --- .github/workflows/solver-web.yml | 6 + docs/web-dynamic-domains.md | 95 ++++++++++++ packages/solver-web/README.md | 42 +++++- packages/solver-web/bridge.c | 2 + packages/solver-web/dynamic.c | 250 +++++++++++++++++++++++++++++++ packages/solver-web/dynamic.h | 8 + packages/solver-web/package.json | 2 +- packages/solver-web/prepare.mjs | 17 ++- packages/solver-web/worker.mjs | 12 +- tests/benchmark_solver_web.mjs | 40 +++++ tests/test_solver_web.mjs | 67 ++++++++- tests/test_web_dynamic.c | 79 ++++++++++ tests/web_yang_zhang.json | 25 ++++ tools/build_solver_web.py | 10 +- 14 files changed, 630 insertions(+), 25 deletions(-) create mode 100644 docs/web-dynamic-domains.md create mode 100644 packages/solver-web/dynamic.c create mode 100644 packages/solver-web/dynamic.h create mode 100644 tests/benchmark_solver_web.mjs create mode 100644 tests/test_web_dynamic.c create mode 100644 tests/web_yang_zhang.json diff --git a/.github/workflows/solver-web.yml b/.github/workflows/solver-web.yml index 1af3d3a..58db077 100644 --- a/.github/workflows/solver-web.yml +++ b/.github/workflows/solver-web.yml @@ -8,6 +8,8 @@ on: - 'packages/solver-web/**' - 'tools/build_solver_web.py' - 'tests/test_solver_web.mjs' + - 'tests/test_web_dynamic.c' + - 'tests/web_yang_zhang.json' - 'include/wang/**' - 'src/**' - '.github/workflows/solver-web.yml' @@ -47,6 +49,10 @@ jobs: for package in build/solver-web/*/; do node tests/test_solver_web.mjs "$package" done + - name: Check browser engine memory safety and allocation failures + run: | + cc -Iinclude -std=c17 -Wall -Wextra -Werror -O1 -g -fsanitize=address,undefined tests/test_web_dynamic.c -o build/test_web_dynamic + build/test_web_dynamic - name: Retain release archive briefly uses: actions/upload-artifact@v4 with: diff --git a/docs/web-dynamic-domains.md b/docs/web-dynamic-domains.md new file mode 100644 index 0000000..d3c3e4b --- /dev/null +++ b/docs/web-dynamic-domains.md @@ -0,0 +1,95 @@ +# Runtime-sized WebAssembly domains + +The former browser limit came from one `uint32_t` per cell, fixed compatibility +arrays in `src/solver/solver_serial.c`, the browser C bridge, JavaScript shifts +by tile ID, and Studio's active-tile validation. The canonical Yang–Zhang tiles +and native solvers still use their existing representations. + +## Scope and contract + +The web build continues to share `core/tile.c`, `core/region.c`, +`solver/solver_serial.c`, `solver/byte_support_table.c`, both trace modules and +`verify/verify_tiling.c` with native builds. None of those files, native headers, +the builder or native Makefile changed. Only the web build links the new +`packages/solver-web/dynamic.c`. Counts <=32 take the exact existing native-core +path. Larger counts use a private graph solver for both browser selections; +`optimized` above 32 does not imply native byte-support/MRV-index optimizations. + +Domains have `ceil(tile_count/32)` words per cell. Every shift is modulo 32 and +tail bits are validated. Array multiplication and trail growth are checked; +allocation failure returns an error, never UNSAT. The engine performs root +restriction, allowed duplicate-tuple elimination, fixed-point arc consistency, +MRV, iterative DFS and rollback of changed words. Every complete assignment is +checked against original domains, boundaries and reciprocal adjacency before +publication. Decisions live on the heap, avoiding recursion limits. + +The public JS API remains version 1 (edge arrays in, numeric indices out). +Private Wasm ABI 2 transfers multiword domains; the updated Worker decodes them +without converting IDs to `uint8_t` or truncating them to JavaScript bitmasks. +Square instances above 32 tiles use dense graph adjacency. Buffer ownership, +Asyncify pause/resume and Worker Stop/deadline behavior are unchanged. See the +package README for the precise ABI and resource budgets. + +## Validation on 2026-10-12 + +- `make -j4 check`: passed C checks, shared/serial/OpenMP builds, benchmark smoke, + solver comparisons and all 252 Python tests. Native source hashes also match + the source hashes of Studio's previous installed package. +- `node tests/test_solver_web.mjs PACKAGE`: passed 500 existing randomized + differential instances and 100 new 65-tile instances against an independent + exhaustive polygon oracle, for both solver selections. +- Cardinalities 23, 31, 32, 33, 64, 65, 96, 97, 257 and 1025: passed SAT/UNSAT, + enumeration, highest-ID pins and asynchronous pause in all five geometries. +- The actual canonical 23-tile fixture (emitted from `TILESET`) passed SAT and + UNSAT comparisons. Counts are independently compared, not just SAT flags. +- Actual Wasm odd cycles that are arc-consistent but UNSAT, and branches that + fail before a later SAT witness, passed across word boundaries. A failed + 300 MiB allocation returned null; a subsequent small allocation succeeded. +- `tests/test_web_dynamic.c` compiled with AddressSanitizer and UBSan passed, + including leak checking, every reached allocation failure, malformed tail + bits, odd/even cycles and repeated trail reallocations on a 1024-cell ring. +- Studio's Chromium tests passed actual Add/edit controls, JSON download/import, + 23/32/33/64/65 tiles, both solver selections, SAT/UNSAT, highest-ID rendering + and canvas export in all five geometries. Catalogs of 65 generalized atoms + also survived serialization and solved through Wasm. Runtime ABI 2 is asserted + in the UI session; console errors and native API calls fail the harness. +- Studio's six Node editor/geometry/generalized suites and 14 Python tests passed. + +The browser-only memory test is wired into the package CI. Reproduce it with: + +```sh +cc -Iinclude -std=c17 -Wall -Wextra -Werror -O1 -g \ + -fsanitize=address,undefined tests/test_web_dynamic.c -o build/test_web_dynamic +build/test_web_dynamic +``` + +## Remaining resource limits + +There is no tileset-cardinality cap in the web API or engine. Available address +space/memory, 256 MiB Wasm budget, 16 used colors, 128 palette entries, 4096 active +cells, square area budget and session deadline still apply. Large-domain search +is not promised to match the specialized small-domain optimized engine. +Studio retains its 5 MB import budget and its separate 32-cell limit per single +generalized piece; multiple pieces can contribute more than 32 active atoms. +Native runtime-tileset APIs and Studio's optional native connector retain 32. +Validation is local Chromium/static hosting; no public-site deployment was made. + +## Small-tileset benchmark + +`node tests/benchmark_solver_web.mjs OLD_PACKAGE NEW_PACKAGE`, Node 26.8.2, +Emscripten 4.0.23, 2x2 regions, 200 warmups, nine alternating samples of 1000 +complete enumerations per engine. Baseline is Studio's previously pinned +`0.1.0-dev.e74e7f2776b9`. The final web build enables link-time optimization +(`-flto`) only in `build_solver_web.py`; it removed a 9–14% slowdown observed in +intermediate non-LTO builds. Native compiler flags remain unchanged. + +| Case | Old median ms / 1000 | New median ms / 1000 | New / old | +| --- | ---: | ---: | ---: | +| Yang–Zhang 23 Reference | 152.35 | 145.77 | 0.957 | +| Yang–Zhang 23 Optimized | 162.25 | 160.70 | 0.990 | +| 32 Reference | 880.74 | 846.74 | 0.961 | +| 32 Optimized | 887.11 | 866.06 | 0.976 | + +Counts matched exactly: 608 and 4096 configurations per enumeration. No +regression was observed in these measured cases; this is a local microbenchmark, +not a guarantee for all workloads or browser engines. diff --git a/packages/solver-web/README.md b/packages/solver-web/README.md index c7f7ac4..abc37ba 100644 --- a/packages/solver-web/README.md +++ b/packages/solver-web/README.md @@ -2,7 +2,7 @@ This package is built and owned by Tiling Foundry. Independent applications consume the versioned artifacts without copying the native solver source. -It runs the existing reference and optimized C search mechanisms in a dedicated Web Worker. +It runs C search in a dedicated Web Worker; native targets remain unchanged. No Python service, npm runtime dependencies, shared memory, cross-origin isolation headers, or paid compute service is required. @@ -44,14 +44,16 @@ decimal strings too. Duplicate edge tuples are counted once, subject to initial domains. Palette indices are symbolic identities, even when RGB values repeat. Supported geometries: `square`, `tri-up`, `tri-side`, `hex-flat`, `hex-point`. -Square edges are `(N,E,S,W)`, with fixed orientations. Limits are 32 tiles, +Square edges are `(N,E,S,W)`, with fixed orientations. Tile cardinality is runtime-sized (`max_tiles: null`). Resource limits remain 16 used colors, 4096 active cells, 16384 bounding-box cells for squares, and 128 palette entries. `fixed` optionally pins an exact edge tuple. `required` constrains each side; `null` means unconstrained. Negative coordinates, holes and disconnected inputs are supported, but applications may impose stricter research profiles. `solver` selects `optimized` (default) or `reference`. Both execute the shared -Foundry C core with their respective mechanism flags; Python/Z3 is not part of +Foundry C core with their respective mechanism flags for up to 32 tiles. Larger +tilesets use the same browser-only dynamic engine for either selection; they do +not claim the native optimized mechanisms. Python/Z3 is not part of this package. The two native reference/oracle paths remain available in Foundry. The package validates input and independently checks published assignments in @@ -62,7 +64,7 @@ Counting retains a single snapshot and a counter, not a list of solutions. Asyncify suspends the existing C callback for interactive stepping, preserving the DFS stack and trail. Counting yields to the Worker event loop at throttled -progress updates. This adds no alternate search implementation. Asyncify has a +progress updates. Asyncify has a runtime cost; native and browser performance must be benchmarked separately. The controlling thread enforces a deadline per active segment, including when @@ -77,10 +79,38 @@ Triangles use IDs `i,j,half` (half 0 or 1) and three sides; hexagons use IDs neighbors share the same side index (including inverted cells); hexagonal neighbors use `(side+3)%6`. Variants rotate the lattice, without implicit tile rotations. The input adapter builds reciprocal cell/side arcs and calls -`wang_enumerate_graph`, sharing the existing C propagation, MRV, trail and DFS. +the private browser bridge. Small domains share the existing C core; larger +domains use the private dynamic propagation, MRV, trail and iterative DFS. Graph regions use dense active cells, without the square bounding-box limit. -Generalized-piece constraints are not supported. +Generalized pieces compiled into atomic edge tuples are supported; piece metadata +is not interpreted by the solver. Consumers should retain `manifest.json` with experiment results, pin releases, and update only between searches. Integrity hashes identify artifacts; a hash does not establish trust in an otherwise untrusted download source. + +## Domain ABI (version 2) + +The public JavaScript API stays at version 1: input edge tuples and output numeric +array indices do not change. The matched Worker/Wasm pair uses private ABI 2, +recorded as `wasm_abi_version` in the manifest. Do not mix package artifacts. +`foundry_enumerate_graph` takes cell-major `uint32_t` domains with +`ceil(tile_count / 32)` words per cell. Tile `t` is bit `t % 32` in word +`floor(t / 32)`; unused tail bits must be zero. Callback domains have the same +layout and are borrowed until the callback returns. Counts remain cell counts, +not word counts. The Worker copies snapshots before resuming and refreshes heap +views after allocations/growth. It frees input buffers in `finally`. +The historical rectangular entry point retains its single-word contract; the +adapter routes squares larger than 32 tiles through the general graph entry. +No tile ID is narrowed to the native `uint8_t TileId` in the dynamic engine. + +`dynamic.c` is linked only by `build_solver_web.py`. Native sources, headers, +canonical tiles, builder and Makefile are unchanged. Root restrictions and +duplicate-tuple elimination precede arc consistency. Search records changed +words on an undo trail, selects MRV cells, and stores decisions on a heap stack. +Every witness is checked in C and published snapshots are checked independently +in JavaScript. Allocation/size failures return `-1`, never exhaustive UNSAT; +Wasm malloc returns null on exhaustion. The Worker reports an error and releases +its inputs. Current Wasm memory budget is 256 MiB; Stop/deadline terminates the +Worker even while search has no witness. Large-cardinality performance is not +promised to match the specialized native optimized solver. diff --git a/packages/solver-web/bridge.c b/packages/solver-web/bridge.c index f82c4f8..a65f707 100644 --- a/packages/solver-web/bridge.c +++ b/packages/solver-web/bridge.c @@ -1,5 +1,6 @@ /* Browser ABI only. Propagation, enumeration and verification stay in Foundry. */ #include "wang/solver.h" +#include "dynamic.h" #include #include @@ -61,6 +62,7 @@ int foundry_enumerate_graph(int32_t count, int32_t sides, const int32_t *neighbo { if (count <= 0 || sides <= 0 || !initial) return -1; const WangGraph graph = {(size_t)count, (size_t)sides, neighbors, neighbor_sides, boundary, edges}; + if (tile_count > 32) return foundry_dynamic_graph(&graph, tile_count, initial, visit, NULL); const WangSolverOptions options = { .flags = WANG_SOLVE_COLLECT_METRICS, .initial_domains = initial, .initial_domain_count = (size_t)count diff --git a/packages/solver-web/dynamic.c b/packages/solver-web/dynamic.c new file mode 100644 index 0000000..81de88a --- /dev/null +++ b/packages/solver-web/dynamic.c @@ -0,0 +1,250 @@ +#include "dynamic.h" +#include +#include + +typedef struct { + size_t index; + uint32_t old; +} Change; +typedef struct { + size_t cell, next, mark; +} Frame; +typedef struct { + const WangGraph *g; + size_t tiles, words, used, capacity; + uint32_t *domains; + Change *trail; + WangSolverMetrics metrics; +} Search; + +static void *array(size_t n, size_t size) +{ + return n && n <= SIZE_MAX / size ? calloc(n, size) : NULL; +} +static bool has(const Search *s, size_t c, size_t t) +{ + return (s->domains[c * s->words + t / 32] & (UINT32_C(1) << (t % 32))) != 0; +} +static bool change(Search *s, size_t i, uint32_t value) +{ + if (s->domains[i] == value) + return true; + if (s->used == s->capacity) { + size_t cap = s->capacity ? s->capacity * 2 : 256; + if (cap < s->capacity || cap > SIZE_MAX / sizeof(Change)) + return false; + Change *p = realloc(s->trail, cap * sizeof(Change)); + if (!p) + return false; + s->trail = p; + s->capacity = cap; + } + s->trail[s->used++] = (Change){i, s->domains[i]}; + s->domains[i] = value; + ++s->metrics.domain_reductions; + return true; +} +static void undo(Search *s, size_t mark) +{ + while (s->used > mark) { + Change c = s->trail[--s->used]; + s->domains[c.index] = c.old; + } +} +/* Arc consistency: an edge value is supported iff it occurs on the incident + * neighbor side. Revisit all arcs until no word changes. */ +static int propagate(Search *s) +{ + const WangGraph *g = s->g; + size_t before; + do { + before = s->used; + for (size_t c = 0; c < g->cell_count; ++c) { + for (size_t d = 0; d < g->side_count; ++d) { + size_t a = c * g->side_count + d; + int32_t n = g->neighbors[a]; + if (n < 0) + continue; + uint32_t colors = 0; + for (size_t t = 0; t < s->tiles; ++t) + if (has(s, (size_t)n, t)) + colors |= UINT32_C(1) << g->edges[t * g->side_count + g->neighbor_sides[a]]; + for (size_t w = 0; w < s->words; ++w) { + size_t i = c * s->words + w; + uint32_t bits = s->domains[i], kept = bits; + while (bits) { + unsigned b = (unsigned)__builtin_ctz(bits); + size_t t = w * 32 + b; + if (!(colors & (UINT32_C(1) << g->edges[t * g->side_count + d]))) + kept &= ~(UINT32_C(1) << b); + bits &= bits - 1; + } + if (!change(s, i, kept)) + return -1; + } + } + uint32_t any = 0; + for (size_t w = 0; w < s->words; ++w) + any |= s->domains[c * s->words + w]; + if (!any) + return 0; + } + } while (before != s->used); + return 1; +} +static size_t choose(Search *s) +{ + size_t best = SIZE_MAX, minimum = SIZE_MAX; + for (size_t c = 0; c < s->g->cell_count; ++c) { + size_t count = 0; + for (size_t w = 0; w < s->words; ++w) + count += (unsigned)__builtin_popcount(s->domains[c * s->words + w]); + if (count > 1 && count < minimum) { + best = c; + minimum = count; + } + } + return best; +} +static size_t singleton(const Search *s, size_t c) +{ + size_t tile = SIZE_MAX; + for (size_t w = 0; w < s->words; ++w) { + uint32_t bits = s->domains[c * s->words + w]; + if (!bits) + continue; + if (tile != SIZE_MAX || (bits & (bits - 1))) + return SIZE_MAX; + tile = w * 32 + (unsigned)__builtin_ctz(bits); + } + return tile; +} +static bool verify(const Search *s, const uint32_t *initial) +{ + const WangGraph *g = s->g; + for (size_t c = 0; c < g->cell_count; ++c) { + size_t t = singleton(s, c); + if (t >= s->tiles || !(initial[c * s->words + t / 32] & (UINT32_C(1) << (t % 32)))) + return false; + for (size_t d = 0; d < g->side_count; ++d) { + size_t a = c * g->side_count + d; + uint8_t color = g->edges[t * g->side_count + d]; + if (g->boundary[a] != COLOR_NONE && g->boundary[a] != color) + return false; + if (g->neighbors[a] >= 0) { + size_t u = singleton(s, (size_t)g->neighbors[a]); + if (u >= s->tiles || color != g->edges[u * g->side_count + g->neighbor_sides[a]]) + return false; + } + } + } + return true; +} +int foundry_dynamic_graph(const WangGraph *g, size_t tiles, const uint32_t *initial, + WangSolutionVisitor visit, void *userdata) +{ + if (!g || !initial || !visit || !tiles || tiles > SIZE_MAX - 31 || !g->cell_count || + g->cell_count > 4096 || !g->side_count || g->side_count > 6 || !g->neighbors || + !g->neighbor_sides || !g->boundary || !g->edges || tiles > SIZE_MAX / g->side_count) + return -1; + for (size_t i = 0; i < tiles * g->side_count; ++i) + if (g->edges[i] >= COLOR_COUNT) + return -1; + for (size_t c = 0; c < g->cell_count; ++c) + for (size_t d = 0; d < g->side_count; ++d) { + size_t a = c * g->side_count + d; + int32_t n = g->neighbors[a]; + if (g->boundary[a] != COLOR_NONE && g->boundary[a] >= COLOR_COUNT) + return -1; + if (n == -1) + continue; + if (n < 0 || (size_t)n >= g->cell_count || (size_t)n == c || + g->neighbor_sides[a] >= g->side_count) + return -1; + size_t back = (size_t)n * g->side_count + g->neighbor_sides[a]; + if (g->neighbors[back] != (int32_t)c || g->neighbor_sides[back] != d) + return -1; + } + Search s = {.g = g, .tiles = tiles, .words = (tiles + 31) / 32}; + if (s.words > SIZE_MAX / g->cell_count) + return -1; + size_t length = s.words * g->cell_count; + s.domains = array(length, sizeof(uint32_t)); + Frame *stack = array(g->cell_count, sizeof(Frame)); + int result = -1; + if (!s.domains || !stack) + goto done; + memcpy(s.domains, initial, length * sizeof(uint32_t)); + for (size_t c = 0; c < g->cell_count; ++c) { + if (tiles % 32 && (s.domains[(c + 1) * s.words - 1] >> (tiles % 32))) + goto done; + for (size_t t = 0; t < tiles; ++t) + if (has(&s, c, t)) { + bool keep = true; + for (size_t d = 0; d < g->side_count; ++d) { + uint8_t color = g->boundary[c * g->side_count + d]; + if (color != COLOR_NONE && color != g->edges[t * g->side_count + d]) + keep = false; + } + /* Collapse only allowed duplicate tuples, retaining the first ID. */ + for (size_t u = 0; keep && u < t; ++u) + if (has(&s, c, u) && !memcmp(g->edges + u * g->side_count, + g->edges + t * g->side_count, g->side_count)) + keep = false; + if (!keep) + s.domains[c * s.words + t / 32] &= ~(UINT32_C(1) << (t % 32)); + } + } + size_t depth = 0; + int consistent = propagate(&s); + if (consistent < 0) + goto done; + s.used = 0; /* Root pruning is permanent. */ + for (;;) { + if (consistent) { + ++s.metrics.dfs_nodes; + size_t cell = choose(&s); + if (cell == SIZE_MAX) { + if (!verify(&s, initial)) + goto done; + if (!visit(s.domains, g->cell_count, &s.metrics, userdata)) { + result = 1; + goto done; + } + } else { + stack[depth++] = (Frame){cell, 0, s.used}; + } + } + bool branched = false; + while (depth) { + Frame *f = &stack[depth - 1]; + undo(&s, f->mark); + size_t t = f->next; + while (t < tiles && !has(&s, f->cell, t)) + ++t; + if (t == tiles) { + --depth; + ++s.metrics.backtracks; + continue; + } + f->next = t + 1; + for (size_t w = 0; w < s.words; ++w) + if (!change(&s, f->cell * s.words + w, w == t / 32 ? UINT32_C(1) << (t % 32) : 0)) + goto done; + consistent = propagate(&s); + if (consistent < 0) + goto done; + branched = true; + break; + } + if (!branched) { + result = 0; + break; + } + } +done: + free(stack); + free(s.domains); + free(s.trail); + return result; +} diff --git a/packages/solver-web/dynamic.h b/packages/solver-web/dynamic.h new file mode 100644 index 0000000..a91a863 --- /dev/null +++ b/packages/solver-web/dynamic.h @@ -0,0 +1,8 @@ +/* Private browser engine. Domains are cell-major arrays of ceil(tiles/32) words. + * Callback snapshots have the same layout. Native solver APIs are untouched. */ +#ifndef FOUNDRY_WEB_DYNAMIC_H +#define FOUNDRY_WEB_DYNAMIC_H +#include "wang/solver.h" +int foundry_dynamic_graph(const WangGraph *g, size_t tiles, const uint32_t *initial, + WangSolutionVisitor visit, void *userdata); +#endif diff --git a/packages/solver-web/package.json b/packages/solver-web/package.json index 54d9ccc..f66bfba 100644 --- a/packages/solver-web/package.json +++ b/packages/solver-web/package.json @@ -1,6 +1,6 @@ { "name": "@tiling-foundry/solver-web", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "license": "MIT", "exports": "./client.mjs", diff --git a/packages/solver-web/prepare.mjs b/packages/solver-web/prepare.mjs index 65156af..4fa5731 100644 --- a/packages/solver-web/prepare.mjs +++ b/packages/solver-web/prepare.mjs @@ -1,6 +1,6 @@ /* Input validation and independent witness checking. No search implementation. */ export const limits = Object.freeze({ - max_tiles: 32, max_used_colors: 16, max_cells: 4096, max_area: 16384, + max_tiles: null, max_used_colors: 16, max_cells: 4096, max_area: 16384, max_palette: 128, default_timeout_seconds: 30, }); export const geometries = ['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point']; @@ -23,7 +23,7 @@ export function adjacent(geometry, id) { export function prepare(data) { if (!data || !geometries.includes(data.geometry)) throw Error('Geometria non supportata.'); - const graph = data.geometry !== 'square'; + const graph = data.geometry !== 'square' || data.tiles?.length > 32; const triangle = data.geometry.startsWith('tri'); const sides = triangle ? 3 : data.geometry.startsWith('hex') ? 6 : 4; const {palette, tiles, cells} = data; @@ -38,8 +38,8 @@ export function prepare(data) { } return value; } - if (!Array.isArray(tiles) || !tiles.length || tiles.length > limits.max_tiles) { - throw Error('Il solver richiede da 1 a 32 tessere.'); + if (!Array.isArray(tiles) || !tiles.length) { + throw Error('Il solver richiede almeno una tessera.'); } tiles.forEach(t => edges(t)); if (!Array.isArray(cells) || !cells.length || cells.length > limits.max_cells) { @@ -74,12 +74,13 @@ export function prepare(data) { y0 = Math.min(y0, y); y1 = Math.max(y1, y); } const width = x1 - x0 + 1, height = y1 - y0 + 1, area = graph ? cells.length : width * height; - if (area > limits.max_area) throw Error('Il rettangolo della regione supera 16384 celle.'); + if ((data.geometry === 'square' ? width * height : area) > limits.max_area) throw Error('Il rettangolo della regione supera 16384 celle.'); if (used.size > limits.max_used_colors) throw Error('Il solver supporta al massimo 16 colori usati.'); // Colors are symbolic palette indices. Equal RGB values remain distinct. const remap = new Map([...used].sort((a, b) => a - b).map((v, i) => [v, i])); const active = new Uint8Array(area), boundary = new Uint8Array(area * sides).fill(255); - const domains = new Uint32Array(area), indices = new Map(); + const words = Math.ceil(tiles.length / 32); + const domains = new Uint32Array(area * words), indices = new Map(); const neighbors = new Int32Array(area * sides).fill(-1); const neighborSides = new Uint8Array(area * sides); for (const cell of records.values()) indices.set(cell.id, graph ? indices.size : (cell.y-y0)*width+cell.x-x0); @@ -90,7 +91,7 @@ export function prepare(data) { tiles.forEach((tile, t) => { if ((!cell.fixed || tile.every((v, d) => v === cell.fixed[d])) && cell.required.every((v, d) => v === null || tile[d] === v)) { - domains[index] = (domains[index] | (1 << t)) >>> 0; + domains[index * words + Math.floor(t / 32)] |= 1 << (t % 32); } }); adjacent(data.geometry, cell.id).forEach(([id, side], d) => { @@ -101,7 +102,7 @@ export function prepare(data) { } }); } - return {geometry: data.geometry, graph, sides, neighbors, neighborSides, width, height, active, boundary, domains, indices, records, tiles, + return {geometry: data.geometry, words, graph, sides, neighbors, neighborSides, width, height, active, boundary, domains, indices, records, tiles, edges: new Uint8Array(tiles.flat().map(v => remap.get(v))), timeout, mode, solver}; } diff --git a/packages/solver-web/worker.mjs b/packages/solver-web/worker.mjs index 383eb24..916c0fb 100644 --- a/packages/solver-web/worker.mjs +++ b/packages/solver-web/worker.mjs @@ -26,9 +26,15 @@ async function run(payload) { const snapshot = pointer => { const result = {}; for (const [id, index] of prepared.indices) { - const mask = module.HEAPU32[(pointer >>> 2) + index]; - if (!mask || (mask & (mask - 1)) !== 0) throw Error('Dominio non singleton nella soluzione.'); - result[id] = 31 - Math.clz32(mask); + let tile = -1; + for (let word = 0; word < prepared.words; word++) { + const mask = module.HEAPU32[(pointer >>> 2) + index * prepared.words + word]; + if (!mask) continue; + if (tile !== -1 || (mask & (mask - 1)) !== 0) throw Error('Dominio non singleton nella soluzione.'); + tile = word * 32 + 31 - Math.clz32(mask); + } + if (tile < 0 || tile >= prepared.tiles.length) throw Error('Indice tessera non valido.'); + result[id] = tile; } return checkAssignments(prepared, result); }; diff --git a/tests/benchmark_solver_web.mjs b/tests/benchmark_solver_web.mjs new file mode 100644 index 0000000..9de84e3 --- /dev/null +++ b/tests/benchmark_solver_web.mjs @@ -0,0 +1,40 @@ +// Usage: node tests/benchmark_solver_web.mjs OLD_PACKAGE NEW_PACKAGE +// Alternate modules within each sample to avoid systematic warmup/thermal bias. +import {resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {readFile} from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import {prepare} from '../packages/solver-web/prepare.mjs'; +const canonical=JSON.parse(await readFile(new URL('./web_yang_zhang.json',import.meta.url))); +const engines=[]; +for(const directory of process.argv.slice(2)) { + const {default:create}=await import(pathToFileURL(resolve(directory)+'/foundry.mjs')); + engines.push({directory,module:await create(),cases:{}}); +} +for(const [name,tiles] of [['yang-zhang-23',canonical],['32',Array.from({length:32},(_,i)=>[i%4,Math.floor(i/4)%2,0,Math.floor(i/8)])]]) { + for(const solver of ['reference','optimized']) { + const p=prepare({geometry:'square',palette:Array(16).fill('#ffffff'),tiles,solver, + cells:Array.from({length:4},(_,i)=>({id:`${i%2},${Math.floor(i/2)}`,required:[null,null,null,null]}))}); + const runners=engines.map(engine=>{ + const module=engine.module,pointers=[]; + const alloc=a=>{const x=module._malloc(a.byteLength);pointers.push(x);module.HEAPU8.set(new Uint8Array(a.buffer),x);return x;}; + const args=[p.width,p.height,alloc(p.active),alloc(p.boundary),alloc(p.edges),tiles.length,alloc(p.domains),Number(solver==='reference')]; + let count=0;module.onSolution=()=>{count++;return 0;}; + const run=()=>{count=0;assert.equal(module._foundry_enumerate(...args),0);return count;}; + const expected=run();for(let i=0;i<200;i++)assert.equal(run(),expected); + return {engine,run,pointers,expected,times:[]}; + }); + assert.ok(runners.every(r=>r.expected===runners[0].expected)); + for(let sample=0;sample<9;sample++)for(const r of sample%2?[...runners].reverse():runners) { + const start=performance.now();for(let i=0;i<1000;i++)r.run();r.times.push(performance.now()-start); + } + for(const r of runners) { + r.times.sort((a,b)=>a-b); + r.engine.cases[name+' '+solver]={median_ms_per_1000:r.times[4],solutions:r.expected}; + r.pointers.forEach(x=>r.engine.module._free(x)); + } + } +} +console.log(JSON.stringify(engines.map(({directory,cases})=>({directory,cases})),null,2)); +if(engines.length===2)for(const key of Object.keys(engines[0].cases)) + console.log(key+': new/old '+(engines[1].cases[key].median_ms_per_1000/engines[0].cases[key].median_ms_per_1000).toFixed(3)); diff --git a/tests/test_solver_web.mjs b/tests/test_solver_web.mjs index fe40ea1..72abae6 100644 --- a/tests/test_solver_web.mjs +++ b/tests/test_solver_web.mjs @@ -33,7 +33,7 @@ function brute(data) { return; } const cell = data.cells[index], [x,y] = cell.id.split(',').map(Number); - for (const tile of data.tiles) { + for (const tile of new Map(data.tiles.map(t=>[JSON.stringify(t),t])).values()) { if (cell.fixed && tile.some((v,d) => v !== cell.fixed[d])) continue; if (cell.required.some((v,d) => v !== null && tile[d] !== v)) continue; if (Object.entries(placed).some(([id,other])=>keys[cell.id].some((key,d)=>{ @@ -58,7 +58,7 @@ async function enumerate(data, pause) { }; module.onSolution = domains => { const assigned = Object.fromEntries([...prepared.indices].map(([id,index]) => - [id,31-Math.clz32(module.HEAPU32[(domains>>>2)+index])])); + [id,Array.from({length:prepared.words},(_,w)=>module.HEAPU32[(domains>>>2)+index*prepared.words+w]).reduce((t,b,w)=>b?w*32+31-Math.clz32(b):t,-1)])); checkAssignments(prepared,assigned); results.push(JSON.stringify(data.cells.map(c => data.tiles[assigned[c.id]]))); return pause ? 1 : 0; @@ -133,3 +133,66 @@ for(const geometry of ['tri-up','tri-side','hex-flat','hex-point']) { assert.throws(()=>checkAssignments(prepare(base),{})); } console.log('PASS: all four triangular/hexagonal variants, 400 random instances against independent polygon oracle, both C engines, pause, fixed tiles, duplicates, disconnected cells and bit 31.'); + +for (const n of [23,31,32,33,64,65,96,97,257,1025]) { + for (const geometry of ['square','tri-up','tri-side','hex-flat','hex-point']) { + const sides=geometry.startsWith('tri')?3:geometry==='square'?4:6; + const data={geometry,palette:Array(16).fill('#ffffff'), + tiles:Array.from({length:n},(_,t)=>Array.from({length:sides},(_,d)=>Math.floor(t/16**d)%16)), + cells:[{id:sides===3?'0,0,0':'0,0',required:Array(sides).fill(null)}]}; + for (const solver of ['reference','optimized']) { + data.solver=solver; + assert.deepEqual(await enumerate(data),brute(data),`${geometry} ${n} enumeration`); + data.cells[0].fixed=data.tiles[n-1]; + assert.deepEqual(await enumerate(data,true),brute(data),`${n} high ID fixed`); + data.cells[0].required[sides-1]=15; + assert.deepEqual(await enumerate(data),[],`${n} UNSAT`); + data.cells[0].required.fill(null);delete data.cells[0].fixed; + } + } +} +for (let trial=0;trial<100;trial++) { + const data=instance(Array.from({length:65},()=>Array.from({length:4},()=>random(2)))); + for (const cell of data.cells) cell.required=Array.from({length:4},()=>random(6)===0?random(2):null); + const expected=brute(data); + for (const solver of ['reference','optimized']) assert.deepEqual(await enumerate({...data,solver}),expected,`dynamic oracle ${trial}`); +} +console.log('PASS: runtime cardinalities 23/31/32/33/64/65/96/97/257/1025, all geometries, SAT/UNSAT, high IDs, pause, 100 dynamic differential cases.'); + +// Canonical fixture emitted from TILESET in src/core/tile.c, without modifying it. +const {readFile}=await import('node:fs/promises'); +const canonical=JSON.parse(await readFile(new URL('./web_yang_zhang.json',import.meta.url))); +assert.equal(canonical.length,23); +for(const solver of ['reference','optimized']) { + const data={...instance(canonical),solver,palette:Array(16).fill('#ffffff')}; + assert.deepEqual(await enumerate(data),brute(data),'canonical Yang–Zhang'); + data.cells[0].required=[15,15,15,15]; + assert.deepEqual(await enumerate(data),[],'canonical UNSAT'); +} +console.log('PASS: canonical Yang–Zhang 23-tile SAT/UNSAT against independent oracle.'); + +// Explicit cycles force branching: arc consistency alone cannot reject an odd +// cycle of opposite colors. A final uniform tile provides a SAT branch after +// failed choices, proving undo restores words before the published witness. +for(const n of [33,64,65,96,97,257,1025])for(const sat of [false,true]) { + const words=Math.ceil(n/32),cells=3,ptrs=[]; + const alloc=a=>{const p=module._malloc(a.byteLength);assert.ok(p);ptrs.push(p);module.HEAPU8.set(new Uint8Array(a.buffer),p);return p;}; + const neighbors=Int32Array.from([2,1,0,2,1,0]),sides=Uint8Array.from([1,0,1,0,1,0]); + const edges=new Uint8Array(n*2),domains=new Uint32Array(cells*words); + edges.set([0,1,1,0,2,2],(n-3)*2); + for(let c=0;c{ + count++; + for(let c=0;c>>2)+c*words+w],w===Math.floor((n-1)/32)?(2**((n-1)%32))>>>0:0); + return 0; + }; + try { + const args=[cells,2,alloc(neighbors),alloc(sides),alloc(new Uint8Array(6).fill(255)),alloc(edges),n,alloc(domains),1]; + assert.equal(await module.ccall('foundry_enumerate_graph','number',Array(9).fill('number'),args,{async:true}),0); + assert.equal(count,sat?1:0); + }finally{ptrs.forEach(p=>module._free(p));} +} +assert.equal(module._malloc(300*1024*1024),0,'Wasm resource exhaustion returns null, not abort'); +const recovered=module._malloc(16);assert.ok(recovered);module._free(recovered); +console.log('PASS: actual Wasm arc-consistent UNSAT cycles, failed branches then SAT, undo across words and recoverable memory exhaustion.'); diff --git a/tests/test_web_dynamic.c b/tests/test_web_dynamic.c new file mode 100644 index 0000000..d92e8aa --- /dev/null +++ b/tests/test_web_dynamic.c @@ -0,0 +1,79 @@ +/* Standalone test of the browser-only implementation, with allocation faults. + * cc -Iinclude -std=c17 -Wall -Wextra -Werror -fsanitize=address,undefined + * tests/test_web_dynamic.c -o build/test_web_dynamic */ +#include +#include +#include +#include +static size_t calls, fail_at = SIZE_MAX; +static void *test_calloc(size_t n, size_t z) { return calls++ == fail_at ? NULL : calloc(n, z); } +static void *test_realloc(void *p, size_t z) { return calls++ == fail_at ? NULL : realloc(p, z); } +#define calloc test_calloc +#define realloc test_realloc +#include "../packages/solver-web/dynamic.c" +#undef calloc +#undef realloc +static size_t solutions; +static bool visit(const uint32_t *d, size_t n, const WangSolverMetrics *m, void *u) +{ + (void)d; + (void)n; + (void)u; + (void)m; + ++solutions; + return true; +} +static void ring(size_t tiles, size_t cells) +{ + size_t words = (tiles + 31) / 32; + uint8_t *edges = calloc(tiles * 2, 1); + uint32_t *initial = calloc(cells * words, sizeof(uint32_t)); + int32_t *neighbors = calloc(cells * 2, sizeof(int32_t)); + uint8_t *sides = calloc(cells * 2, 1), *boundary = calloc(cells * 2, 1); + assert(edges && initial && neighbors && sides && boundary); + edges[(tiles - 2) * 2] = 0; + edges[(tiles - 2) * 2 + 1] = 1; + edges[(tiles - 1) * 2] = 1; + edges[(tiles - 1) * 2 + 1] = 0; + for (size_t c = 0; c < cells; c++) { + neighbors[c * 2] = (int32_t)((c + cells - 1) % cells); + neighbors[c * 2 + 1] = (int32_t)((c + 1) % cells); + sides[c * 2] = 1; + sides[c * 2 + 1] = 0; + boundary[c * 2] = boundary[c * 2 + 1] = 255; + for (size_t t = tiles - 2; t < tiles; t++) + initial[c * words + t / 32] |= UINT32_C(1) << (t % 32); + } + WangGraph g = {cells, 2, neighbors, sides, boundary, edges}; + calls = solutions = 0; + fail_at = SIZE_MAX; + assert(foundry_dynamic_graph(&g, tiles, initial, visit, NULL) == 0); + assert(solutions == (cells % 2 ? 0 : 2)); + size_t allocations = calls; + for (size_t fail = 0; fail < allocations; fail++) { + calls = solutions = 0; + fail_at = fail; + assert(foundry_dynamic_graph(&g, tiles, initial, visit, NULL) == -1); + } + fail_at = SIZE_MAX; + if (tiles % 32) { + initial[words - 1] |= UINT32_C(1) << (tiles % 32); + assert(foundry_dynamic_graph(&g, tiles, initial, visit, NULL) == -1); + } + free(edges); + free(initial); + free(neighbors); + free(sides); + free(boundary); +} +int main(void) +{ + const size_t counts[] = {23, 31, 32, 33, 64, 65, 96, 97, 257, 1025}; + for (size_t i = 0; i < sizeof(counts) / sizeof(*counts); i++) { + ring(counts[i], 3); + ring(counts[i], 4); + } + ring(65, 1024); /* Grow the undo trail repeatedly; fail each realloc. */ + puts("PASS dynamic: arc-consistent odd-cycle UNSAT, even-cycle SAT, rollback, high IDs, " + "invalid tail bits, every allocation fault."); +} diff --git a/tests/web_yang_zhang.json b/tests/web_yang_zhang.json new file mode 100644 index 0000000..77dfdb0 --- /dev/null +++ b/tests/web_yang_zhang.json @@ -0,0 +1,25 @@ +[ +[0,2,7,1], +[7,2,8,1], +[8,2,0,1], +[0,3,0,1], +[0,4,0,2], +[0,4,9,3], +[9,3,0,2], +[0,2,0,2], +[0,3,0,3], +[5,2,5,2], +[5,3,5,3], +[0,10,6,2], +[6,2,0,10], +[0,11,6,3], +[6,3,0,11], +[6,2,12,2], +[12,2,5,2], +[6,2,13,3], +[13,3,5,2], +[6,3,14,2], +[14,2,5,3], +[6,3,15,3], +[15,3,5,3] +] diff --git a/tools/build_solver_web.py b/tools/build_solver_web.py index 82d3003..99156bd 100644 --- a/tools/build_solver_web.py +++ b/tools/build_solver_web.py @@ -15,13 +15,13 @@ 'src/core/tile.c', 'src/core/region.c', 'src/solver/solver_serial.c', 'src/solver/byte_support_table.c', 'src/solver/failed_leaf_trace.c', 'src/solver/solver_event_trace.c', 'src/verify/verify_tiling.c', - 'packages/solver-web/bridge.c', + 'packages/solver-web/bridge.c', 'packages/solver-web/dynamic.c', ] FLAGS = [ - '-std=c17', '-Wall', '-Wextra', '-Werror', '-O3', '-Iinclude', '--no-entry', + '-std=c17', '-Wall', '-Wextra', '-Werror', '-O3', '-flto', '-Iinclude', '--no-entry', '-sMODULARIZE=1', '-sEXPORT_ES6=1', '-sENVIRONMENT=worker,node', '-sASYNCIFY=1', '-sASYNCIFY_STACK_SIZE=65536', - '-sALLOW_MEMORY_GROWTH=1', '-sMAXIMUM_MEMORY=268435456', + '-sALLOW_MEMORY_GROWTH=1', '-sABORTING_MALLOC=0', '-sMAXIMUM_MEMORY=268435456', '-sSTACK_SIZE=1048576', '-sFILESYSTEM=0', '-sEXPORTED_FUNCTIONS=["_foundry_enumerate","_foundry_enumerate_graph","_malloc","_free"]', '-sEXPORTED_RUNTIME_METHODS=["ccall","HEAPU8","HEAPU32"]', @@ -65,11 +65,11 @@ def main(): 'README.md', 'LICENSE', 'package.json'] manifest = dict( name=descriptor['name'], version=version, api_version=1, solvers=['optimized', 'reference'], - runtime='wasm', geometries=['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point'], + wasm_abi_version=2, runtime='wasm', geometries=['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point'], source=dict(repository='https://github.com/xtraid/tiling-foundry', revision=revision, dirty=dirty, sha256=source_hash, files=source_files), build=dict(compiler=compiler, flags=FLAGS), - limits=dict(max_tiles=32, max_used_colors=16, max_cells=4096, max_area=16384), + limits=dict(max_tiles=None, max_used_colors=16, max_cells=4096, max_area=16384), files={name: dict(sha256=sha256(output / name), size=(output / name).stat().st_size) for name in artifacts}, ) (output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') From 434b7098d6ee2ac2bb62ea4a3fee6fd8c2fa9e27 Mon Sep 17 00:00:00 2001 From: Manuel Magnabosco Date: Mon, 12 Oct 2026 15:29:27 +0200 Subject: [PATCH 7/7] Scale browser color domains and make solver releases reproducible --- packages/solver-web/README.md | 30 +++++--- .../solver-web/VALIDATION.md | 23 ++++-- packages/solver-web/bridge.c | 8 ++- packages/solver-web/client.mjs | 7 +- packages/solver-web/dynamic.c | 72 +++++++++++++------ packages/solver-web/dynamic.h | 13 +++- packages/solver-web/package.json | 2 +- packages/solver-web/prepare.mjs | 29 ++++---- packages/solver-web/worker.mjs | 2 +- tests/test_solver_web.mjs | 37 +++++++++- tests/test_web_dynamic.c | 35 ++++++++- tools/build_solver_web.py | 24 +++++-- 12 files changed, 214 insertions(+), 68 deletions(-) rename docs/web-dynamic-domains.md => packages/solver-web/VALIDATION.md (81%) diff --git a/packages/solver-web/README.md b/packages/solver-web/README.md index abc37ba..95ad7f8 100644 --- a/packages/solver-web/README.md +++ b/packages/solver-web/README.md @@ -17,7 +17,9 @@ python tools/build_solver_web.py The archive and its SHA-256 are written to `build/solver-web/`. Development versions include a digest of the actual source inputs; the manifest also records the Git revision and whether the checkout was dirty. `--release` requires a -clean checkout and uses the version in `packages/solver-web/package.json`. +clean checkout and Emscripten 4.0.23, and uses the version in +`packages/solver-web/package.json`. Archive timestamps, ownership and permissions +are normalized; rebuilding the same revision with that SDK yields identical bytes. ## Consume @@ -45,14 +47,16 @@ domains. Palette indices are symbolic identities, even when RGB values repeat. Supported geometries: `square`, `tri-up`, `tri-side`, `hex-flat`, `hex-point`. Square edges are `(N,E,S,W)`, with fixed orientations. Tile cardinality is runtime-sized (`max_tiles: null`). Resource limits remain -16 used colors, 4096 active cells, 16384 bounding-box cells for squares, and 128 palette -entries. `fixed` optionally pins an exact edge tuple. `required` constrains each +65536 used colors, 65536 active cells and 65536 palette entries. Sparse square +regions use active-cell graphs when their bounding box exceeds the native adapter +budget, so no bounding-box size limit applies. `fixed` optionally pins an exact edge tuple. `required` constrains each side; `null` means unconstrained. Negative coordinates, holes and disconnected inputs are supported, but applications may impose stricter research profiles. -`solver` selects `optimized` (default) or `reference`. Both execute the shared -Foundry C core with their respective mechanism flags for up to 32 tiles. Larger -tilesets use the same browser-only dynamic engine for either selection; they do +`solver` selects `optimized` (default) or `reference`. Within the native limits +(up to 32 tiles, 16 used colors and 4096 cells), both execute the shared +Foundry C core with their respective mechanism flags. Inputs exceeding those +limits use the same browser-only dynamic engine for either selection; they do not claim the native optimized mechanisms. Python/Z3 is not part of this package. The two native reference/oracle paths remain available in Foundry. @@ -69,7 +73,9 @@ runtime cost; native and browser performance must be benchmarked separately. The controlling thread enforces a deadline per active segment, including when the native search has found no solution. Progress never resets that deadline. -Pause switches to a ten-minute idle expiry; Next/Count starts a new segment. +The default active deadline is 600 seconds. `timeout_seconds: 0` disables it. +Pause uses `idle_timeout_seconds` (default 0, no expiry); Next/Count starts a new +active segment. Both timers accept up to 2147483 seconds. Stop/timeout terminates the Worker and retains the last published lower bound. Browser timer throttling can delay enforcement in background tabs. A closed page ends its computations; there is no server-side persistence. @@ -89,10 +95,10 @@ Consumers should retain `manifest.json` with experiment results, pin releases, and update only between searches. Integrity hashes identify artifacts; a hash does not establish trust in an otherwise untrusted download source. -## Domain ABI (version 2) +## Domain and color ABI (version 3) The public JavaScript API stays at version 1: input edge tuples and output numeric -array indices do not change. The matched Worker/Wasm pair uses private ABI 2, +array indices do not change. The matched Worker/Wasm pair uses private ABI 3, recorded as `wasm_abi_version` in the manifest. Do not mix package artifacts. `foundry_enumerate_graph` takes cell-major `uint32_t` domains with `ceil(tile_count / 32)` words per cell. Tile `t` is bit `t % 32` in word @@ -102,6 +108,10 @@ not word counts. The Worker copies snapshots before resuming and refreshes heap views after allocations/growth. It frees input buffers in `finally`. The historical rectangular entry point retains its single-word contract; the adapter routes squares larger than 32 tiles through the general graph entry. +The graph entry adds a `wide_colors` argument. When true, edge and boundary +arrays contain `uint32_t` color IDs, with `UINT32_MAX` as the unconstrained +boundary sentinel. Otherwise byte arrays retain their previous representation. +Color support uses runtime-sized stamp arrays, not a fixed-width color mask. No tile ID is narrowed to the native `uint8_t TileId` in the dynamic engine. `dynamic.c` is linked only by `build_solver_web.py`. Native sources, headers, @@ -111,6 +121,6 @@ words on an undo trail, selects MRV cells, and stores decisions on a heap stack. Every witness is checked in C and published snapshots are checked independently in JavaScript. Allocation/size failures return `-1`, never exhaustive UNSAT; Wasm malloc returns null on exhaustion. The Worker reports an error and releases -its inputs. Current Wasm memory budget is 256 MiB; Stop/deadline terminates the +its inputs. Current Wasm linear-memory ceiling is 2 GiB (allocation can fail earlier); Stop/deadline terminates the Worker even while search has no witness. Large-cardinality performance is not promised to match the specialized native optimized solver. diff --git a/docs/web-dynamic-domains.md b/packages/solver-web/VALIDATION.md similarity index 81% rename from docs/web-dynamic-domains.md rename to packages/solver-web/VALIDATION.md index d3c3e4b..860a562 100644 --- a/docs/web-dynamic-domains.md +++ b/packages/solver-web/VALIDATION.md @@ -11,8 +11,8 @@ The web build continues to share `core/tile.c`, `core/region.c`, `solver/solver_serial.c`, `solver/byte_support_table.c`, both trace modules and `verify/verify_tiling.c` with native builds. None of those files, native headers, the builder or native Makefile changed. Only the web build links the new -`packages/solver-web/dynamic.c`. Counts <=32 take the exact existing native-core -path. Larger counts use a private graph solver for both browser selections; +`packages/solver-web/dynamic.c`. Inputs within the native limits (32 tiles, 16 colors, 4096 cells) take the +existing native-core path. Larger inputs use a private graph solver for both browser selections; `optimized` above 32 does not imply native byte-support/MRV-index optimizations. Domains have `ceil(tile_count/32)` words per cell. Every shift is modulo 32 and @@ -24,13 +24,13 @@ checked against original domains, boundaries and reciprocal adjacency before publication. Decisions live on the heap, avoiding recursion limits. The public JS API remains version 1 (edge arrays in, numeric indices out). -Private Wasm ABI 2 transfers multiword domains; the updated Worker decodes them +Private Wasm ABI 3 transfers multiword domains and optional 32-bit colors; the updated Worker decodes them without converting IDs to `uint8_t` or truncating them to JavaScript bitmasks. Square instances above 32 tiles use dense graph adjacency. Buffer ownership, Asyncify pause/resume and Worker Stop/deadline behavior are unchanged. See the package README for the precise ABI and resource budgets. -## Validation on 2026-10-12 +## Initial multiword validation on 2026-10-12 - `make -j4 check`: passed C checks, shared/serial/OpenMP builds, benchmark smoke, solver comparisons and all 252 Python tests. Native source hashes also match @@ -44,7 +44,7 @@ package README for the precise ABI and resource budgets. UNSAT comparisons. Counts are independently compared, not just SAT flags. - Actual Wasm odd cycles that are arc-consistent but UNSAT, and branches that fail before a later SAT witness, passed across word boundaries. A failed - 300 MiB allocation returned null; a subsequent small allocation succeeded. + allocation above the Wasm memory ceiling returned null; a subsequent small allocation succeeded. - `tests/test_web_dynamic.c` compiled with AddressSanitizer and UBSan passed, including leak checking, every reached allocation failure, malformed tail bits, odd/even cycles and repeated trail reallocations on a 1024-cell ring. @@ -66,8 +66,9 @@ build/test_web_dynamic ## Remaining resource limits There is no tileset-cardinality cap in the web API or engine. Available address -space/memory, 256 MiB Wasm budget, 16 used colors, 128 palette entries, 4096 active -cells, square area budget and session deadline still apply. Large-domain search +space/memory, a 2 GiB Wasm ceiling, 65536 used colors/palette entries, 65536 active +cells and configurable session deadlines still apply. Color support uses dynamic +stamp arrays; the wide boundary sentinel is UINT32_MAX, so 255 remains a color. Large-domain search is not promised to match the specialized small-domain optimized engine. Studio retains its 5 MB import budget and its separate 32-cell limit per single generalized piece; multiple pieces can contribute more than 32 active atoms. @@ -93,3 +94,11 @@ intermediate non-LTO builds. Native compiler flags remain unchanged. Counts matched exactly: 608 and 4096 configurations per enumeration. No regression was observed in these measured cases; this is a local microbenchmark, not a guarantee for all workloads or browser engines. + +## Color and release consolidation + +The current regression suite additionally covers 17, 33, 256, 257 and 1025 +colors in all five geometries, adjacent SAT/UNSAT, high palette indices and +sparse square regions. Native sanitizer tests cover wide colors through 65535, +boundary sentinel separation and allocation failures. Release archives normalize +tar/gzip metadata and require the pinned Emscripten 4.0.23 toolchain. diff --git a/packages/solver-web/bridge.c b/packages/solver-web/bridge.c index a65f707..dc30ffb 100644 --- a/packages/solver-web/bridge.c +++ b/packages/solver-web/bridge.c @@ -58,11 +58,15 @@ int foundry_enumerate(int32_t width, int32_t height, const uint8_t *active, EMSCRIPTEN_KEEPALIVE int foundry_enumerate_graph(int32_t count, int32_t sides, const int32_t *neighbors, const uint8_t *neighbor_sides, const uint8_t *boundary, const uint8_t *edges, - size_t tile_count, const uint32_t *initial, int reference) + size_t tile_count, const uint32_t *initial, int reference, int wide_colors) { if (count <= 0 || sides <= 0 || !initial) return -1; const WangGraph graph = {(size_t)count, (size_t)sides, neighbors, neighbor_sides, boundary, edges}; - if (tile_count > 32) return foundry_dynamic_graph(&graph, tile_count, initial, visit, NULL); + if (tile_count > 32 || count > 4096 || wide_colors) { + const FoundryWebGraph web = {(size_t)count, (size_t)sides, neighbors, + neighbor_sides, boundary, edges, wide_colors != 0}; + return foundry_dynamic_graph(&web, tile_count, initial, visit, NULL); + } const WangSolverOptions options = { .flags = WANG_SOLVE_COLLECT_METRICS, .initial_domains = initial, .initial_domain_count = (size_t)count diff --git a/packages/solver-web/client.mjs b/packages/solver-web/client.mjs index b2b3976..7c84aeb 100644 --- a/packages/solver-web/client.mjs +++ b/packages/solver-web/client.mjs @@ -19,14 +19,14 @@ export class Solver { async start(payload) { const copy = structuredClone(payload); - const {timeout, solver} = prepare(copy); + const {timeout, idleTimeout, solver} = prepare(copy); this.reap(); if ([...this.jobs.values()].some(job => !terminal.has(job.result.status))) { throw Error('È già presente una ricerca. Premi Stop prima di avviarne un’altra.'); } // Reserve the slot before awaiting the manifest to prevent concurrent starts. const id = crypto.randomUUID(); - const job = {worker: null, timer: null, generation: 0, timeout, finished: null, + const job = {worker: null, timer: null, generation: 0, timeout, idleTimeout, finished: null, result: {status: 'running', solver, runtime: 'wasm', count: '0', exact: false, generation: 0}}; this.jobs.set(id, job); this.arm(job, timeout * 1000, 'timeout'); @@ -40,7 +40,7 @@ export class Solver { if (terminal.has(job.result.status) || data.generation != null && data.generation !== job.generation) return; if (data.status === 'error') { this.finish(job, 'error', data.message); return; } job.result = {...job.result, ...data}; - if (data.status === 'paused') this.arm(job, 600000, 'expired'); + if (data.status === 'paused') this.arm(job, job.idleTimeout * 1000, 'expired'); else if (terminal.has(data.status)) this.finish(job, data.status); // Progress does not reset the active deadline; resume() starts a segment. }; @@ -56,6 +56,7 @@ export class Solver { arm(job, milliseconds, outcome) { clearTimeout(job.timer); + if (milliseconds === 0) { job.timer = null; return; } job.timer = setTimeout(() => this.finish(job, outcome), milliseconds); } diff --git a/packages/solver-web/dynamic.c b/packages/solver-web/dynamic.c index 81de88a..6b18895 100644 --- a/packages/solver-web/dynamic.c +++ b/packages/solver-web/dynamic.c @@ -10,13 +10,24 @@ typedef struct { size_t cell, next, mark; } Frame; typedef struct { - const WangGraph *g; - size_t tiles, words, used, capacity; + const FoundryWebGraph *g; + size_t tiles, words, used, capacity, color_count; + uint32_t *color_stamps, epoch; uint32_t *domains; Change *trail; WangSolverMetrics metrics; } Search; +static uint32_t edge(const FoundryWebGraph *g, size_t i) +{ + return g->wide_colors ? ((const uint32_t *)g->edges)[i] : ((const uint8_t *)g->edges)[i]; +} +static uint32_t boundary(const FoundryWebGraph *g, size_t i) +{ + uint32_t value = + g->wide_colors ? ((const uint32_t *)g->boundary)[i] : ((const uint8_t *)g->boundary)[i]; + return !g->wide_colors && value == COLOR_NONE ? UINT32_MAX : value; +} static void *array(size_t n, size_t size) { return n && n <= SIZE_MAX / size ? calloc(n, size) : NULL; @@ -55,7 +66,7 @@ static void undo(Search *s, size_t mark) * neighbor side. Revisit all arcs until no word changes. */ static int propagate(Search *s) { - const WangGraph *g = s->g; + const FoundryWebGraph *g = s->g; size_t before; do { before = s->used; @@ -65,17 +76,21 @@ static int propagate(Search *s) int32_t n = g->neighbors[a]; if (n < 0) continue; - uint32_t colors = 0; + if (++s->epoch == 0) { + memset(s->color_stamps, 0, s->color_count * sizeof(uint32_t)); + s->epoch = 1; + } for (size_t t = 0; t < s->tiles; ++t) if (has(s, (size_t)n, t)) - colors |= UINT32_C(1) << g->edges[t * g->side_count + g->neighbor_sides[a]]; + s->color_stamps[edge(g, t * g->side_count + g->neighbor_sides[a])] = + s->epoch; for (size_t w = 0; w < s->words; ++w) { size_t i = c * s->words + w; uint32_t bits = s->domains[i], kept = bits; while (bits) { unsigned b = (unsigned)__builtin_ctz(bits); size_t t = w * 32 + b; - if (!(colors & (UINT32_C(1) << g->edges[t * g->side_count + d]))) + if (s->color_stamps[edge(g, t * g->side_count + d)] != s->epoch) kept &= ~(UINT32_C(1) << b); bits &= bits - 1; } @@ -121,40 +136,48 @@ static size_t singleton(const Search *s, size_t c) } static bool verify(const Search *s, const uint32_t *initial) { - const WangGraph *g = s->g; + const FoundryWebGraph *g = s->g; for (size_t c = 0; c < g->cell_count; ++c) { size_t t = singleton(s, c); if (t >= s->tiles || !(initial[c * s->words + t / 32] & (UINT32_C(1) << (t % 32)))) return false; for (size_t d = 0; d < g->side_count; ++d) { size_t a = c * g->side_count + d; - uint8_t color = g->edges[t * g->side_count + d]; - if (g->boundary[a] != COLOR_NONE && g->boundary[a] != color) + uint32_t color = edge(g, t * g->side_count + d); + if (boundary(g, a) != UINT32_MAX && boundary(g, a) != color) return false; if (g->neighbors[a] >= 0) { size_t u = singleton(s, (size_t)g->neighbors[a]); - if (u >= s->tiles || color != g->edges[u * g->side_count + g->neighbor_sides[a]]) + if (u >= s->tiles || color != edge(g, u * g->side_count + g->neighbor_sides[a])) return false; } } } return true; } -int foundry_dynamic_graph(const WangGraph *g, size_t tiles, const uint32_t *initial, +int foundry_dynamic_graph(const FoundryWebGraph *g, size_t tiles, const uint32_t *initial, WangSolutionVisitor visit, void *userdata) { if (!g || !initial || !visit || !tiles || tiles > SIZE_MAX - 31 || !g->cell_count || - g->cell_count > 4096 || !g->side_count || g->side_count > 6 || !g->neighbors || - !g->neighbor_sides || !g->boundary || !g->edges || tiles > SIZE_MAX / g->side_count) + g->cell_count > FOUNDRY_WEB_MAX_CELLS || !g->side_count || g->side_count > 6 || + !g->neighbors || !g->neighbor_sides || !g->boundary || !g->edges || + tiles > SIZE_MAX / g->side_count) + return -1; + if (tiles * g->side_count > SIZE_MAX / (g->wide_colors ? sizeof(uint32_t) : 1)) return -1; - for (size_t i = 0; i < tiles * g->side_count; ++i) - if (g->edges[i] >= COLOR_COUNT) + size_t color_count = 1; + for (size_t i = 0; i < tiles * g->side_count; ++i) { + uint32_t color = edge(g, i); + if (color >= FOUNDRY_WEB_MAX_COLORS) return -1; + if ((size_t)color + 1 > color_count) + color_count = (size_t)color + 1; + } for (size_t c = 0; c < g->cell_count; ++c) for (size_t d = 0; d < g->side_count; ++d) { size_t a = c * g->side_count + d; int32_t n = g->neighbors[a]; - if (g->boundary[a] != COLOR_NONE && g->boundary[a] >= COLOR_COUNT) + if (boundary(g, a) != UINT32_MAX && boundary(g, a) >= FOUNDRY_WEB_MAX_COLORS) return -1; if (n == -1) continue; @@ -165,14 +188,15 @@ int foundry_dynamic_graph(const WangGraph *g, size_t tiles, const uint32_t *init if (g->neighbors[back] != (int32_t)c || g->neighbor_sides[back] != d) return -1; } - Search s = {.g = g, .tiles = tiles, .words = (tiles + 31) / 32}; + Search s = {.g = g, .tiles = tiles, .words = (tiles + 31) / 32, .color_count = color_count}; if (s.words > SIZE_MAX / g->cell_count) return -1; size_t length = s.words * g->cell_count; s.domains = array(length, sizeof(uint32_t)); + s.color_stamps = array(color_count, sizeof(uint32_t)); Frame *stack = array(g->cell_count, sizeof(Frame)); int result = -1; - if (!s.domains || !stack) + if (!s.domains || !stack || !s.color_stamps) goto done; memcpy(s.domains, initial, length * sizeof(uint32_t)); for (size_t c = 0; c < g->cell_count; ++c) { @@ -182,14 +206,17 @@ int foundry_dynamic_graph(const WangGraph *g, size_t tiles, const uint32_t *init if (has(&s, c, t)) { bool keep = true; for (size_t d = 0; d < g->side_count; ++d) { - uint8_t color = g->boundary[c * g->side_count + d]; - if (color != COLOR_NONE && color != g->edges[t * g->side_count + d]) + uint32_t color = boundary(g, c * g->side_count + d); + if (color != UINT32_MAX && color != edge(g, t * g->side_count + d)) keep = false; } /* Collapse only allowed duplicate tuples, retaining the first ID. */ for (size_t u = 0; keep && u < t; ++u) - if (has(&s, c, u) && !memcmp(g->edges + u * g->side_count, - g->edges + t * g->side_count, g->side_count)) + if (has(&s, c, u) && !memcmp((const uint8_t *)g->edges + + u * g->side_count * (g->wide_colors ? 4 : 1), + (const uint8_t *)g->edges + + t * g->side_count * (g->wide_colors ? 4 : 1), + g->side_count * (g->wide_colors ? 4 : 1))) keep = false; if (!keep) s.domains[c * s.words + t / 32] &= ~(UINT32_C(1) << (t % 32)); @@ -246,5 +273,6 @@ int foundry_dynamic_graph(const WangGraph *g, size_t tiles, const uint32_t *init free(stack); free(s.domains); free(s.trail); + free(s.color_stamps); return result; } diff --git a/packages/solver-web/dynamic.h b/packages/solver-web/dynamic.h index a91a863..adc374a 100644 --- a/packages/solver-web/dynamic.h +++ b/packages/solver-web/dynamic.h @@ -3,6 +3,17 @@ #ifndef FOUNDRY_WEB_DYNAMIC_H #define FOUNDRY_WEB_DYNAMIC_H #include "wang/solver.h" -int foundry_dynamic_graph(const WangGraph *g, size_t tiles, const uint32_t *initial, +#define FOUNDRY_WEB_MAX_CELLS 65536u +#define FOUNDRY_WEB_MAX_COLORS 65536u +/* Byte colors retain ABI 2 compatibility; wide colors use uint32_t with + * UINT32_MAX for unconstrained boundaries. Native WangGraph is unchanged. */ +typedef struct { + size_t cell_count, side_count; + const int32_t *neighbors; + const uint8_t *neighbor_sides; + const void *boundary, *edges; + bool wide_colors; +} FoundryWebGraph; +int foundry_dynamic_graph(const FoundryWebGraph *g, size_t tiles, const uint32_t *initial, WangSolutionVisitor visit, void *userdata); #endif diff --git a/packages/solver-web/package.json b/packages/solver-web/package.json index f66bfba..d1c030a 100644 --- a/packages/solver-web/package.json +++ b/packages/solver-web/package.json @@ -1,6 +1,6 @@ { "name": "@tiling-foundry/solver-web", - "version": "0.2.0", + "version": "0.3.0", "type": "module", "license": "MIT", "exports": "./client.mjs", diff --git a/packages/solver-web/prepare.mjs b/packages/solver-web/prepare.mjs index 4fa5731..445140e 100644 --- a/packages/solver-web/prepare.mjs +++ b/packages/solver-web/prepare.mjs @@ -1,7 +1,8 @@ /* Input validation and independent witness checking. No search implementation. */ export const limits = Object.freeze({ - max_tiles: null, max_used_colors: 16, max_cells: 4096, max_area: 16384, - max_palette: 128, default_timeout_seconds: 30, + max_tiles: null, max_used_colors: 65536, max_cells: 65536, max_area: null, + max_palette: 65536, default_timeout_seconds: 600, max_timeout_seconds: 2147483, + default_idle_timeout_seconds: 0, max_memory_bytes: 2147483648, }); export const geometries = ['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point']; const offsets = [[0, -1], [1, 0], [0, 1], [-1, 0]]; @@ -23,7 +24,7 @@ export function adjacent(geometry, id) { export function prepare(data) { if (!data || !geometries.includes(data.geometry)) throw Error('Geometria non supportata.'); - const graph = data.geometry !== 'square' || data.tiles?.length > 32; + let graph = data.geometry !== 'square' || data.tiles?.length > 32; const triangle = data.geometry.startsWith('tri'); const sides = triangle ? 3 : data.geometry.startsWith('hex') ? 6 : 4; const {palette, tiles, cells} = data; @@ -43,11 +44,12 @@ export function prepare(data) { } tiles.forEach(t => edges(t)); if (!Array.isArray(cells) || !cells.length || cells.length > limits.max_cells) { - throw Error('La regione deve contenere da 1 a 4096 celle.'); + throw Error('La regione deve contenere da 1 a 65536 celle.'); } const timeout = data.timeout_seconds ?? limits.default_timeout_seconds; - if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout < 1 || timeout > 120) { - throw Error('Tempo limite non valido (1–120 secondi).'); + const idleTimeout = data.idle_timeout_seconds ?? limits.default_idle_timeout_seconds; + if ([timeout, idleTimeout].some(t => typeof t !== 'number' || !Number.isFinite(t) || t < 0 || t > limits.max_timeout_seconds)) { + throw Error('Tempo limite non valido (0 = senza scadenza; massimo 2147483 secondi).'); } const mode = data.enumeration_mode ?? 'step'; if (!['step', 'count'].includes(mode)) throw Error('Modalità di enumerazione non valida.'); @@ -73,12 +75,15 @@ export function prepare(data) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); } - const width = x1 - x0 + 1, height = y1 - y0 + 1, area = graph ? cells.length : width * height; - if ((data.geometry === 'square' ? width * height : area) > limits.max_area) throw Error('Il rettangolo della regione supera 16384 celle.'); - if (used.size > limits.max_used_colors) throw Error('Il solver supporta al massimo 16 colori usati.'); + const width = x1 - x0 + 1, height = y1 - y0 + 1; + const wide = used.size > 16; + graph ||= wide || cells.length > 4096 || width * height > 16384; + const area = graph ? cells.length : width * height; + if (used.size > limits.max_used_colors) throw Error('Il solver supporta al massimo 65536 colori usati.'); // Colors are symbolic palette indices. Equal RGB values remain distinct. const remap = new Map([...used].sort((a, b) => a - b).map((v, i) => [v, i])); - const active = new Uint8Array(area), boundary = new Uint8Array(area * sides).fill(255); + const ColorArray = wide ? Uint32Array : Uint8Array; + const active = new Uint8Array(area), boundary = new ColorArray(area * sides).fill(wide ? 0xffffffff : 255); const words = Math.ceil(tiles.length / 32); const domains = new Uint32Array(area * words), indices = new Map(); const neighbors = new Int32Array(area * sides).fill(-1); @@ -102,8 +107,8 @@ export function prepare(data) { } }); } - return {geometry: data.geometry, words, graph, sides, neighbors, neighborSides, width, height, active, boundary, domains, indices, records, tiles, - edges: new Uint8Array(tiles.flat().map(v => remap.get(v))), timeout, mode, solver}; + return {geometry: data.geometry, words, wide, graph, sides, neighbors, neighborSides, width, height, active, boundary, domains, indices, records, tiles, + edges: new ColorArray(tiles.flat().map(v => remap.get(v))), timeout, idleTimeout, mode, solver}; } export function checkAssignments(prepared, assignments) { diff --git a/packages/solver-web/worker.mjs b/packages/solver-web/worker.mjs index 916c0fb..75e16f4 100644 --- a/packages/solver-web/worker.mjs +++ b/packages/solver-web/worker.mjs @@ -80,7 +80,7 @@ async function run(payload) { try { const args = prepared.graph ? [prepared.records.size, prepared.sides, allocate(prepared.neighbors), allocate(prepared.neighborSides), allocate(prepared.boundary), allocate(prepared.edges), - prepared.tiles.length, allocate(prepared.domains), Number(prepared.solver === 'reference')] : [prepared.width, prepared.height, allocate(prepared.active), + prepared.tiles.length, allocate(prepared.domains), Number(prepared.solver === 'reference'), Number(prepared.wide)] : [prepared.width, prepared.height, allocate(prepared.active), allocate(prepared.boundary), allocate(prepared.edges), prepared.tiles.length, allocate(prepared.domains), Number(prepared.solver === 'reference')]; const status = await module.ccall(prepared.graph ? 'foundry_enumerate_graph' : 'foundry_enumerate', 'number', diff --git a/tests/test_solver_web.mjs b/tests/test_solver_web.mjs index 72abae6..b0d3b6c 100644 --- a/tests/test_solver_web.mjs +++ b/tests/test_solver_web.mjs @@ -67,7 +67,7 @@ async function enumerate(data, pause) { try { const args = prepared.graph ? [prepared.records.size,prepared.sides,allocate(prepared.neighbors), allocate(prepared.neighborSides),allocate(prepared.boundary),allocate(prepared.edges), - prepared.tiles.length,allocate(prepared.domains),Number(prepared.solver==='reference')] : [prepared.width,prepared.height,allocate(prepared.active), + prepared.tiles.length,allocate(prepared.domains),Number(prepared.solver==='reference'),Number(prepared.wide)] : [prepared.width,prepared.height,allocate(prepared.active), allocate(prepared.boundary),allocate(prepared.edges),prepared.tiles.length, allocate(prepared.domains),Number(prepared.solver==='reference')]; assert.equal(await module.ccall(prepared.graph?'foundry_enumerate_graph':'foundry_enumerate','number',Array(args.length).fill('number'),args,{async:true}),0); @@ -193,6 +193,39 @@ for(const n of [33,64,65,96,97,257,1025])for(const sat of [false,true]) { assert.equal(count,sat?1:0); }finally{ptrs.forEach(p=>module._free(p));} } -assert.equal(module._malloc(300*1024*1024),0,'Wasm resource exhaustion returns null, not abort'); +assert.equal(module._malloc(0xffffffff),0,'Wasm resource exhaustion returns null, not abort'); const recovered=module._malloc(16);assert.ok(recovered);module._free(recovered); console.log('PASS: actual Wasm arc-consistent UNSAT cycles, failed branches then SAT, undo across words and recoverable memory exhaustion.'); + +// Cross the former color-mask and byte-sentinel boundaries on adjacent cells. +for (const colors of [17, 33, 256, 257, 1025]) { + for (const geometry of ['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point']) { + const sides = geometry.startsWith('tri') ? 3 : geometry === 'square' ? 4 : 6; + const data = {geometry, palette: Array(colors).fill('#ffffff'), + tiles: Array.from({length: colors}, (_,t) => Array(sides).fill(t)), + cells: (sides === 3 ? ['0,0,0','0,0,1'] : ['0,0','1,0']).map(id => + ({id, required: Array(sides).fill(null)}))}; + for (const solver of ['reference', 'optimized']) { + data.solver = solver; + data.cells[0].fixed = data.tiles[colors-1]; + assert.deepEqual(await enumerate(data,true), brute(data), `${geometry} ${colors} colors`); + data.cells[1].required[0] = 0; + assert.deepEqual(await enumerate(data), [], 'Wide color conflict'); + data.cells[1].required.fill(null); + } + } +} +const sparse = instance([[0,0,0,0]],1,1); +sparse.cells.push({id:'1000000,-1000000',required:[null,null,null,null]}); +assert.deepEqual(await enumerate(sparse),brute(sparse),'Sparse square bypasses rectangular budget'); +const largePalette = {...instance([[65535,65535,65535,65535]],1,1),palette:Array(65536).fill('#ffffff')}; +assert.deepEqual(await enumerate(largePalette),brute(largePalette),'Last supported palette index'); +assert.throws(()=>prepare({...largePalette,palette:Array(65537).fill('#ffffff')}),/Palette/); +assert.throws(()=>prepare({...sparse,timeout_seconds:2147484}),/Tempo/); +assert.throws(()=>prepare({...sparse,idle_timeout_seconds:-1}),/Tempo/); +assert.equal(prepare({...sparse,timeout_seconds:0}).timeout,0); +console.log('PASS: wide symbolic colors, 255 sentinel separation, adjacent SAT/UNSAT, all geometries, sparse regions and operational limits.'); +const largeRegion = instance([[0,0,0,0]],4097,1); +assert.equal(prepare(largeRegion).graph,true); +assert.equal((await enumerate(largeRegion)).length,1,'More than 4096 active cells'); +assert.throws(()=>prepare({...largeRegion,cells:Array(65537).fill(largeRegion.cells[0])}),/65536/); diff --git a/tests/test_web_dynamic.c b/tests/test_web_dynamic.c index d92e8aa..9246a6b 100644 --- a/tests/test_web_dynamic.c +++ b/tests/test_web_dynamic.c @@ -44,7 +44,7 @@ static void ring(size_t tiles, size_t cells) for (size_t t = tiles - 2; t < tiles; t++) initial[c * words + t / 32] |= UINT32_C(1) << (t % 32); } - WangGraph g = {cells, 2, neighbors, sides, boundary, edges}; + FoundryWebGraph g = {cells, 2, neighbors, sides, boundary, edges, false}; calls = solutions = 0; fail_at = SIZE_MAX; assert(foundry_dynamic_graph(&g, tiles, initial, visit, NULL) == 0); @@ -66,6 +66,38 @@ static void ring(size_t tiles, size_t cells) free(sides); free(boundary); } +static void wide_colors(void) +{ + const int32_t neighbors[] = {1, 0}; + const uint8_t sides[] = {0, 0}; + uint32_t edges[] = {255, 65535}; + uint32_t boundary[] = {UINT32_MAX, UINT32_MAX}; + const uint32_t initial[] = {3, 3}; + FoundryWebGraph g = {2, 1, neighbors, sides, boundary, edges, true}; + solutions = calls = 0; + assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == 0); + assert(solutions == 2); + const size_t allocations = calls; + for (size_t fail = 0; fail < allocations; ++fail) { + calls = 0; + fail_at = fail; + assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == -1); + } + fail_at = SIZE_MAX; + boundary[0] = 255; + solutions = 0; + assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == 0); + assert(solutions == 1); /* 255 is a color, not the wide sentinel. */ + boundary[1] = 65535; + solutions = 0; + assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == 0); + assert(solutions == 0); + boundary[1] = 65536; + assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == -1); + boundary[1] = UINT32_MAX; + edges[1] = 65536; + assert(foundry_dynamic_graph(&g, 2, initial, visit, NULL) == -1); +} int main(void) { const size_t counts[] = {23, 31, 32, 33, 64, 65, 96, 97, 257, 1025}; @@ -74,6 +106,7 @@ int main(void) ring(counts[i], 4); } ring(65, 1024); /* Grow the undo trail repeatedly; fail each realloc. */ + wide_colors(); puts("PASS dynamic: arc-consistent odd-cycle UNSAT, even-cycle SAT, rollback, high IDs, " "invalid tail bits, every allocation fault."); } diff --git a/tools/build_solver_web.py b/tools/build_solver_web.py index 99156bd..15f7447 100644 --- a/tools/build_solver_web.py +++ b/tools/build_solver_web.py @@ -2,6 +2,7 @@ """Build an independently consumable, fingerprinted browser solver package.""" import argparse import hashlib +import gzip import json from pathlib import Path import re @@ -21,7 +22,7 @@ '-std=c17', '-Wall', '-Wextra', '-Werror', '-O3', '-flto', '-Iinclude', '--no-entry', '-sMODULARIZE=1', '-sEXPORT_ES6=1', '-sENVIRONMENT=worker,node', '-sASYNCIFY=1', '-sASYNCIFY_STACK_SIZE=65536', - '-sALLOW_MEMORY_GROWTH=1', '-sABORTING_MALLOC=0', '-sMAXIMUM_MEMORY=268435456', + '-sALLOW_MEMORY_GROWTH=1', '-sABORTING_MALLOC=0', '-sMAXIMUM_MEMORY=2147483648', '-sSTACK_SIZE=1048576', '-sFILESYSTEM=0', '-sEXPORTED_FUNCTIONS=["_foundry_enumerate","_foundry_enumerate_graph","_malloc","_free"]', '-sEXPORTED_RUNTIME_METHODS=["ccall","HEAPU8","HEAPU32"]', @@ -38,6 +39,8 @@ def main(): parser.add_argument('--release', action='store_true', help='Require a clean checkout; use the release version.') args = parser.parse_args() compiler = subprocess.check_output([args.emcc, '--version'], text=True).splitlines()[0] + if args.release and not re.search(r'\b4\.0\.23\b', compiler): + parser.error('Release builds require Emscripten 4.0.23.') revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() dirty = bool(subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=normal'], cwd=ROOT)) if args.release and dirty: @@ -65,18 +68,27 @@ def main(): 'README.md', 'LICENSE', 'package.json'] manifest = dict( name=descriptor['name'], version=version, api_version=1, solvers=['optimized', 'reference'], - wasm_abi_version=2, runtime='wasm', geometries=['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point'], + wasm_abi_version=3, runtime='wasm', geometries=['square', 'tri-up', 'tri-side', 'hex-flat', 'hex-point'], source=dict(repository='https://github.com/xtraid/tiling-foundry', revision=revision, dirty=dirty, sha256=source_hash, files=source_files), build=dict(compiler=compiler, flags=FLAGS), - limits=dict(max_tiles=None, max_used_colors=16, max_cells=4096, max_area=16384), + limits=dict(max_tiles=None, max_used_colors=65536, max_cells=65536, max_area=None, + max_palette=65536, max_memory_bytes=2147483648, default_timeout_seconds=600, + default_idle_timeout_seconds=0), files={name: dict(sha256=sha256(output / name), size=(output / name).stat().st_size) for name in artifacts}, ) (output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') archive = build_root / (output.name + '.tar.gz') - with tarfile.open(archive, 'w:gz') as tar: - for name in artifacts + ['manifest.json']: - tar.add(output / name, arcname='package/' + name) + # Normalize gzip and tar metadata so the same revision/toolchain produces + # the same archive regardless of build time, checkout path or file owner. + with archive.open('wb') as raw, gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode='w', format=tarfile.USTAR_FORMAT) as tar: + for name in artifacts + ['manifest.json']: + info = tarfile.TarInfo('package/' + name) + info.size = (output / name).stat().st_size + info.mode = 0o644 + with (output / name).open('rb') as source: + tar.addfile(info, source) digest = sha256(archive) archive.with_name(archive.name + '.sha256').write_text(f'{digest} {archive.name}\n') print(json.dumps(dict(package=str(output), archive=str(archive), sha256=digest, version=version), indent=2))