clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / openspec / changes / archive / 2026-09-24-scale-solver-state-search / design.md
design.md 7.5K source

Context

tools/solve.mjs runs the authoritative engine, but each breadth-first node holds a cloned settled snapshot and a full path string. Its key manually concatenates object IDs, positions, nouns, and automaton phases. Current world drafts need 2 to 4 million expansions. W6 exposed a restore bug that advanced a noun transformation without a turn; that is fixed separately in 8ecc483 and is now a prerequisite for differential search checks. The world's older /tmp/cw compact simulator demonstrates a possible data layout, but its rule and contact semantics predate W6 and cannot be adopted as the engine implementation.

Goals / Non-Goals

Goals:

  • Keep shortest-path, BYPASS, and --replay behavior while reducing frontier memory; make --forbid mean the rule was never active and report HANDS-OFF separately.
  • Give the solver one canonical, round-trippable identity for every state variable that affects future transitions.
  • Add an event-free transition path that shares engine semantics and is differential-tested against public gameplay transitions before activation.
  • Make future mechanics register their dynamic snapshot fields before a solver can explore them.

Non-Goals:

  • Change puzzle rules, authored world content, Gift/Workshop presentation, or the public getState() snapshot format.
  • Treat a search-cap result as proof that a level is unsolvable.
  • Introduce gates, switches, crumble tiles, or their visual treatment in this change.

Decisions

The codec is authored-level specific

Build stable object and automaton slot tables once from the normalized level. Encode each object's presence, cell, and current noun in authored-ID order, followed by automaton phases in stable system-ID order. Use a versioned byte representation with bounded integer encoding, and derive a stable map key from those bytes. A missing object gets an explicit sentinel. A noun dictionary covers authored object nouns and nouns available as word predicates; an unknown runtime noun fails loudly. The codec decodes to a settled snapshot accepted by game.restore() and keeps static level definitions outside each search node.

Rules, sentence spans, active carriers, won, display fields, and move count are not part of current nonterminal state equivalence: rules and carriers derive from objects; won states terminate before enqueue; depth gives the shortest move count; automata phase is stored explicitly. A future mechanic must declare dynamic snapshot fields through a codec extension. Unknown dynamic fields cause an error instead of merging distinct states. Static authored fields such as terrain or word text stay in the level definition.

Alternative considered: canonical JSON of every full snapshot. It is easy to inspect but retains repeated static data and leaves memory proportional to snapshot size. The byte codec is more work but can also serve as the branch payload.

Use one queue with a head index and one compact blob per visited state. Each node stores its parent index and action; reconstruct a path only for a solved node. Keep transition-expansion counting and the configured search cap stable. The existing real-engine transition remains the baseline and differential oracle.

The search API also accepts an explicit maxVisited bound. It checks the full codec bytes before counting a new state, so hash collisions cannot hide a state or consume the bound for a duplicate. Hitting the retained-state bound returns LIMIT with reason visited; the transition cap returns reason attempts. The default remains unrestricted by visited count until the world-authoring workflow chooses per-proof budgets.

Alternative considered: retain one frontier array per depth and shorten only the key. That still stores full snapshots and copies path strings at every branch.

The fast path shares mechanics, not a second rule engine

Introduce an engine search transition boundary that takes compact state plus a direction or wait and returns either a blocked result or the next compact state with minimal provenance facts: whether the player pushed a word, whether any actor pushed a word, and which rules were active at each evaluation phase. It omits animation events, presentation clones, and history. Extract or reuse the same parser, movement, transformation, automata, and contact functions as ordinary turns rather than copying the older compact simulator. Enable the solver fast path only after deterministic differential fixtures pass for stacked words, W6 precedence, transformations, removal, automata phases, and Undo/restore equivalence. Keep the public engine path as fallback during rollout.

Alternative considered: copy /tmp/cw/engine-fast.js or sim.mjs. Those files encode older STOP/DEFEAT/stacked-reading behavior and would make CI disagree with play.

Solver constraints inspect every evaluated rule phase

--forbid means the named rule must never be active, including the initial board and intermediate rule evaluations before automata or contact destruction. The fast transition's rule trace is the evidence. A future mustForm CI check can reuse that trace, but metadata naming and gate policy remain in the worlds team's authoring change. BYPASS means the player never pushes a word; HANDS-OFF means no word moves by any cause. The transition provenance is evaluated along the route rather than inferred from the final board.

Performance is measured on authored work

Record expansions, elapsed time, and peak memory for core, Word Lab, Crossword, Crate Works, Tidewater, and Clockwork under the same cap before and after each stage. Use equal route and outcome comparisons; timing is reported, not hard-coded as a cross-machine test threshold. The 2 to 4 million expansion worlds are acceptance cases rather than an assumed 30× speed promise.

Risks / Trade-offs

  • [Risk] A codec omits a new dynamic field and merges futures. → Require a versioned dynamic-state registry and fail closed on unknown fields; add a two-states-same-board/different-mechanic fixture when each mechanic lands.
  • [Risk] The no-event path diverges from gameplay rules. → Share phase functions and keep differential tests that compare next state and victory across representative reachable states before switching the solver.
  • [Risk] Compact decoding and map-key conversion save memory but cost CPU. → Benchmark each stage separately and retain the authoritative fallback until measured.
  • [Risk] A transient rule can activate and disappear in one turn. → Capture each rule-evaluation phase for --forbid instead of checking only settled rules.
  • [Risk] Browser and Node environments have different byte/string behavior. → Use deterministic integer encoding with explicit round-trip tests; the solver remains a Node authoring tool.

Migration Plan

  1. Add codec and round-trip tests against current settled snapshots; keep the old solver search path.
  2. Switch BFS storage to compact nodes and parent links; compare solver routes and BYPASS flags with the baseline.
  3. Add and differential-test the event-free engine transition; enable it in the solver only after parity and performance checks.
  4. Coordinate the per-proof CI search-budget field with the worlds team in the world-authoring OpenSpec change. Rollback is a solver-side fallback to the public engine path; public gameplay state stays unchanged.

Open Questions

  • The worlds notes use both solverBudget and solverMax. Their author-facing field name is pending the worlds team's reply and belongs to the world-authoring workflow.