# Generation Package — Review 01 Action Plan

Source: architectural + correctness review of `meep/src/generation`, 2026-06-11.
Paths below are relative to this directory unless noted.

## Scope rulings (from review discussion)

These override the raw review findings:

1. **No string-keyed factories.** One-class-per-operation stays. We accept the
   verbosity of `CellFilterAdd` etc. in exchange for typed, greppable, JSDoc-able
   code. Boilerplate reduction must come from better *base classes*, never from
   `X.of('add', fn)`-style magic.
2. **This is a library.** "Nothing imports it" is not an argument for removal.
   Unreferenced classes are public surface: bugs in them get **fixed**, not
   deleted. Removal is only on the table for code that *cannot work for any
   caller* (non-functional stubs), and that is a per-item decision.
3. **`prototype*.js` files are internal prototyping tools**, never published.
   Out of scope for this plan.
4. House rules that shape the fixes: correctness first (no bandaids), black-box
   tests with real objects (no mocks, no call-count assertions), Jest runs from
   the `moh` root with the root `jest.conf.json`, asserts are free in prod,
   module-static pools are accepted style (guard them, don't remove them),
   prefer required parameters over null-means-default sentinels.

**Output-stability flag:** items marked `[output-changing]` alter generated
levels for a fixed seed. Each such fix must be verified visually (see
*Verification protocol* at the bottom) and batched consciously — they are the
reason this plan is phased.

---

## Phase 1 — Surgical bug fixes

Local, mechanically obvious fixes. Each lands with a black-box spec.

- [x] **1.1 `MarkerNodeMatcherAnd.match` implements OR.**
  `markers/matcher/MarkerNodeMatcherAnd.js`
  Fix: `if (!this.left.match(node)) return false; return this.right.match(node);`
  Test: spec with two `MarkerNodeMatcherByType`/`ContainsTag` matchers over a real
  `MarkerNode`; truth-table all four combinations. Do the same table for `Or` and
  `Not` while in there — the family has zero coverage.
  `[output-changing]` — restores intended semantics in
  `app/src/generator/generator/objectives/GENERATE_BOSS_OBJECTIVE.js` and
  `generateMusic.js`. While fixing, read both call sites and confirm AND is what
  they want (it is what they wrote).

- [x] **1.2 Road relaxation typo.**
  `grid/generation/road/GridTaskGenerateRoads.js:287`
  Fix: `distances[neighbour_index] = distances;` → `= distance;`
  Test: covered by the end-to-end roads test in 1.3 (the branch is hard to force
  in isolation; the e2e test plus an `assert.isNumber` in the loop is enough).

- [x] **1.3 Road groups self-connect and are then skipped.**
  `grid/generation/road/GridTaskGenerateRoads.js` (node-encounter branch, ~line 336)
  Fix: skip own-group nodes the same way the path-encounter branch skips own-group
  paths: `if (targetGroupId === groupId) continue;` before the
  `directGroupConnectionExists` check.
  Test (e2e, black-box): small `GridData`, all-traversable layer, two marker
  groups of **two adjacent connectors each**, placed apart; run the task to
  completion; assert a `RoadConnection` exists whose endpoints belong to
  *different* groups, and that every group is reachable in the connection graph.
  Note: requires the matcher/action parameters from 5.1, or temporarily importing
  the sample matchers in the spec (acceptable for the test, not for src).
  Also in this file: delete the leftover `console.log(path)` in `tDrawPaths`, and
  the constructed-but-unused `Graph` instance.

- [x] **1.4 `GridTaskExecuteRuleTimes` never terminates when the pattern can't match.**
  `grid/generation/GridTaskExecuteRuleTimes.js`
  Fix: `closed.set(index, true)` after each attempted cell (match or not), and
  make the exhaustion check count closed cells (`closedCount >= gridSize`)
  instead of the linear-probe counter `i >= endIndex`.
  Semantics note: this closes cells whose match could become true later as the
  grid mutates. That is the original design intent (the `closed` set exists);
  document it in the class doc.
  Test: rule whose pattern never matches → task ends with failure within one
  pass; rule that can match exactly `k < count` cells → terminates, `k`
  placements. Used live in `AlienGridGenerator` / boss objective — confirm the
  failure signal (`EndFailure`) is acceptable to those flows or whether
  exhaustion should be `EndSuccess` with fewer placements. **Decision:** failure
  vs. partial success on exhaustion.

- [x] **1.6 `MarkerNodeTransformerYRotateByFilter` Euler convention mismatch.**
  `markers/transform/MarkerNodeTransformerYRotateByFilter.js`
  Fix: `toEulerAnglesXYZ` → `toEulerAnglesYXZ`, matching the recompose call and
  the sibling `…YRotateByFilterGradient`.
  Test: node with a known non-trivial prior YXZ rotation (x ≠ 0), constant
  filter; assert resulting quaternion equals `fromEulerAnglesYXZ(x, expectedY, z)`.
  `[output-changing]` for rotated decor in `theme-1/theme-2 injectDecor`.

- [x] **1.7 `ThemeEngine.applyNodes` searches a stale-length influence array.**
  `theme/ThemeEngine.js` (applyNodes)
  Fix: pass explicit bounds to `binarySearchLowIndex(themeInfluence, t, cmp, 0, matchingThemeCount - 1)`
  (mirroring the weighted selects). Handle `influenceSum === 0`: pick uniformly
  among the matching themes using the same `random` stream (deterministic), since
  "all themes at mask-distance 0" is a legitimate boundary state.
  Test: grid with two overlapping `AreaTheme` masks; a marker matching 3 themes
  processed immediately before one matching 1 theme; assert the second node is
  processed by its only matching theme. Use a test-local recording
  `MarkerNodeAction` subclass (a real action, observable behaviour — not a mock).
  `[output-changing]` at theme boundaries.

- [x] **1.8 `MarkerNodeActionPaintTerrain`: clamp the splat region.**
  `markers/actions/terrain/MarkerNodeActionPaintTerrain.js`
  Fix (this phase, pure bug): clamp `x0/x1/y0/y1` to `[0, splat_resolution-1]`
  so edge markers stop wrapping writes into the opposite row. Make the X/Y
  region rounding symmetric (`floor(v + 0.5)` both axes).
  The deeper grid↔texel mapping question (the `+ splat_to_grid` "cell center"
  shift) is deferred to Phase 3 — leave a `TODO(review-01 §3)` comment.
  Test: marker at grid (0,0) with radius reaching off-map; assert no texel in the
  rightmost column / other rows changes. `[output-changing]` within one texel of
  map edges only.

- [x] **1.9 `ContinuousGridCellActionWriteObstacle` writes global coords.**
  `grid/actions/ContinuousGridCellActionWriteObstacle.js`
  Fix: `to.writePoint(local_x, local_y, …)`. Library surface — fix despite no
  internal callers. Test: obstacle target with non-zero `GridPosition`; execute at
  a global coordinate; assert the *local* cell is written.

- [x] **1.10 `MarkerNode.overlaps` compares the wrong plane.**
  `markers/MarkerNode.js`
  Fix: use `this.position` / `other.position` (grid-space Vector2), matching
  `GridDataNodePredicateOverlaps`. Library surface; fix, add a 3-case spec
  (overlap / touch / disjoint).

- [x] **1.11 `CellFilterLiteralBoolean` cannot execute.**
  `filtering/boolean/CellFilterLiteralBoolean.js`
  Fix: implement `execute() { return this.value ? 1 : 0; }` (the package-wide
  boolean-as-number convention, same as `CellFilterCellMatcher`). Add `TRUE` /
  `FALSE` singletons alongside `CellFilterLiteralFloat.ONE/ZERO` for symmetry.
  Spec: both values, plus `dataType === DataType.Boolean`.

- [x] **1.12 Weighted selects: total weight 0 still executes an element.**
  `placement/action/random/weighted/CellActionSelectWeightedRandom.js`,
  `markers/actions/probability/MarkerNodeActionSelectWeighted.js`
  Fix: if `totalWeight <= 0`, execute nothing. (Folded into 5.2's shared helper;
  if 5.2 is deferred, fix both copies now.)
  Test: all-zero weight filters → no action executed (recording action).

- [x] **1.13 Small fixes batch** (no individual specs needed beyond existing suites):
  - `GridTaskGroup.catchGeneratorErrors` — include `reason` in the
    `console.error` (currently drops the actual error).
  - `GridTaskApplyActionToCells.from` — apply the `name` parameter; rename the
    misleading `fitness` field to `supplier` (or `selector`) to match the API.
  - `CellSupplierBestN` — implement `count()` (`this.elements.length`) so task
    progress works.
  - `GridTaskGenerateRoads` — assert integer connector positions before
    `index = x + y * width` (fractional positions silently corrupt the search).
  - `GridData.resize` — assert message typo (`'width'` on the height check).
  - `CellFilterInverseLerp.from` — JSDoc for `f`/`value` is copy-pasted from Lerp.
  - `sampler_from_filter` — assert says non-negative, doc says positive; make it
    `greaterThan(0)`.
  - `GridTaskConnectRooms` — move the unconditional `drawSamplerHTML` import into
    the (commented) debug path or a lazy import so headless bundles don't pull
    DOM helpers. Keep `debugState`/`drawDebugState` (internal tooling).
  - `GridActionRuleSet.process` — seed the `Random` policy iterator
    (`ArrayIteratorRandom.setSeed(seed)`) so rule order responds to the world
    seed. `[output-changing]` for `Random`-policy rule sets.

- [x] **1.14 Re-entrancy guards on module-static pools.**
  Module-static pools are house style and stay. Add cheap in-use assert guards
  (free in prod) where recursion through user code is possible:
  `GridData.marker_collection` (matcher could re-query the grid),
  `CellFilterCurvature`'s normal scratch (curvature-of-curvature),
  `CellFilterLookupTable.sample`, `RoadConnectionNetwork.scratch_paths`.
  Pattern: `assert.ok(!pool_in_use, 're-entrant use of <pool>')` + try/finally
  flag toggle is NOT acceptable (silent catch rule) — use a plain set/unset; an
  assert firing in dev is the goal.

---

## Phase 2 — Semantic decisions, then fixes

Each item changes generated output by design. Decide the intended semantics,
fix, then verify via the sample generators (see Verification protocol).

- [x] **2.1 Cellular automata: synchronous update.**
  `automata/CaveGeneratorCellularAutomata.js`, `grid/generation/discrete/GridTaskCellularAutomata.js`
  Current `step` mutates in place → sequential CA with scan-order bias.
  Decision A: change `step(data, …)` to `step(src, dst, …)` and ping-pong the two
  buffers across the 50 iterations in `GridTaskCellularAutomata` (signature
  change to the `CellularAutomata` base — library surface, sweep subclasses).
  Decision B (same time): out-of-bounds neighbor policy. Current clamping
  double-counts edge cells; the conventional cave-CA choice is OOB = wall
  (alive), which seals cave borders. Margin seeding interaction: margins start
  dead but can currently come alive — decide whether margins should be frozen.
  Also: write `0/1`, not `false/true`, into the `Uint8Array`.
  Test: synchronicity — a glider-style asymmetric seed must produce the same
  result as a reference two-buffer implementation; plus determinism (same seed →
  identical field). `[output-changing]` — all CA-generated caves.

- [x] **2.2 Fractal noise octave direction.**
  `filtering/numeric/complex/CellFilterSimplexNoise.js` (`fractal`)
  `scale` is a wavelength; passing `scale * frequency` makes successive octaves
  *lower* frequency at *lower* amplitude — inverted fBm. Intended:
  `scale / frequency`.
  Decision: fix to conventional fBm, or keep current output and rename/document?
  Recommendation: fix; the parameter names (`octaves`, `persistence`,
  `lacunarity`) promise conventional fBm. Grep `fractal(` call sites first and
  eyeball each generated surface after.
  Test: spectral sanity — with 3 octaves, correlation length of the output must
  *decrease* as octave count rises (or simpler: octave-2 filter alone must have
  shorter wavelength than octave-1; assert via zero-crossing counts along a row).
  `[output-changing]`.

- [x] **2.3 Gaussian blur sigma units.**
  `filtering/numeric/complex/CellFilterGaussianBlur.js`
  `sigma = radius * 3` in kernel-index units makes the kernel flat (a box blur).
  For a real Gaussian: sigma ≈ kernel half-width / 3, i.e.
  `sigma = (samples - 1) / 6` in index units.
  Decision: is the box-like character relied upon visually? If yes, keep and
  rename the comment; if no, fix the constant. Recommendation: fix, since the
  class advertises Gaussian and callers tuned radius, not shape.
  Test: kernel center weight vs. edge weight ratio > some threshold (e.g. center
  ≥ 3× edge for quality 3) via `build_gaussian_kernel_2d` inspection — that's
  testing the helper's contract, not implementation internals.
  `[output-changing]` — all blurred filters.

- [x] **2.4 Heightfield normal convention (sign of gradient).**
  `filtering/numeric/complex/CellFilterAngleToNormal.js`,
  `filtering/numeric/complex/CellFilterCurvature.js`
  Both build normals as `(+dX, +dY, 2)`; a true surface normal is
  `∝ (−dX, −dY, 2)`. AngleToNormal is unaffected for the default vertical
  reference; directional references get mirrored slopes; Curvature's sign is
  inverted vs. its cited reference shader.
  Fix: extract one shared `computeFilterSurfaceNormal(result, filter, grid, x, y)`
  helper (also de-duplicates the two implementations), with the negated-gradient
  convention, and document the axis mapping (grid x,y,height → normal x,y,z).
  Audit the two consumers for baked-in compensations before flipping.
  Test: synthetic ramp filter (`h = x`): normal must tilt *toward −x*; curvature
  of a paraboloid bump must be positive at the peak (pick and document the
  convention).
  `[output-changing]` where non-default references / curvature thresholds are used.

- [x] **2.5 `GridActionRuleSet` rotation/probability coupling.**
  `markers/GridActionRuleSet.js`
  Today each rotation re-rolls probability: symmetric patterns get effective
  `1−(1−p)⁴` and rotation 0 is favored.
  Decision: intended semantics. Recommendation: roll once per (cell × rule ×
  pattern-offset), then scan rotations for the first match — "does this rule
  fire here?" and "in which orientation?" become independent.
  Test: probability 0.5 rule, rotation-symmetric matcher, large grid, fixed
  seed: firing rate within tolerance of 0.5 (black-box statistical bound);
  rotation histogram roughly uniform across matching rotations if that's the
  chosen semantic. `[output-changing]`.

- [x] **2.6 ThemeEngine blending math.**
  `theme/ThemeEngine.js`, `theme/TerrainLayerRuleAggregator.js`
  - Remove the `influence = distance / matchingThemeCount` divisor — it cancels
    in both consumers (verified no-op; pure simplification, output-stable).
  - `aggregator.normalize(255)` normalizes by **L2**; splat weights conventionally
    sum to 255 (L1). Investigate what the terrain shader expects before changing.
    If shader renormalizes, L2→L1 is output-stable; otherwise `[output-changing]`.
  - Guard `d === 0` (no theme contributes) explicitly instead of relying on
    `NaN → uint8 0`.
  - `tResample` half-texel: source coords should be
    `(x + 0.5) / splatWidth * weights.width − 0.5`; decide if the visual shift
    is worth fixing while in here. `[output-changing]` (sub-texel).

- [x] **2.7 Stateful transformers vs. estimation/test paths.**
  `grid/generation/GridTaskDensityMarkerDistribution.js` (`estimateTapCount`),
  `markers/GridCellActionPlaceMarkerGroup.js` (`testNodeCollisions`),
  `markers/transform/MarkerNodeTransformerRecordUniqueRandomEnum.js`
  `buildNode` runs transformers; estimation/collision-test paths therefore
  *consume* from stateful transformers (UniqueRandomEnum's pool drains before
  real placement).
  Fixes, in order of preference:
  1. `estimateTapCount` doesn't need nodes at all — it uses only `node.size`.
     Compute size as `action.size * scale.sampleRandom(random)` directly; no
     `buildNode` call. (Current transformers never alter `size`; assert that
     invariant in `buildNode` if we rely on it — or just accept estimate drift.)
  2. `testNodeCollisions`: grep callers; it wants post-offset positions, so it
     can't skip transformers blindly. Split `buildNode` into the geometric phase
     (offset/rotation/transform composition) and the transformer phase as two
     named methods; `testNodeCollisions` uses the geometric phase. Document the
     residual inaccuracy for position-moving transformers, or accept running
     transformers there and document the purity requirement.
  3. Document on `MarkerNodeTransformer`: transformers should be pure w.r.t.
     their own state per `transform` call *or* tolerate being called for
     discarded nodes. `RecordUniqueRandomEnum` inherently can't — note it in its
     class doc as incompatible with estimating tasks.
  Test: DensityMarkerDistribution with a UniqueRandomEnum transformer whose pool
  is exactly the expected placement count → must not throw "All values used up".

- [x] **2.8 Seed plumbing decisions.**
  - `MarkerNodeEntityProcessorRandomRotation` ignores the world seed because
    `MarkerNodeEntityProcessor.initialize(data, ecd)` has no seed parameter.
    Add `seed` to the processor interface (sweep: ClingToTerrain, Sequence,
    RandomRotation). Folds into Phase 4's signature unification — do it there.
    `[output-changing]` (entity rotations start varying with seed — that's the point).
  - `GridCellActionPlaceMarkerGroup`'s module-global `idCounter`: uniqueness is
    all that's required today, but it breaks cross-session reproducibility of
    `properties.groupId` values. Decision: leave (document "IDs are opaque,
    equality-only") or derive from a per-generation counter owned by the task.
    Recommendation: document as opaque now; revisit if save/replay needs arise.
  - Standardize derived seeds (`seed + i`, `(s+1)*31`, `seed + 91234`, …) on one
    small typed helper (e.g. `derive_seed(seed, salt)`) — mechanical, low risk,
    `[output-changing]` wherever applied, so batch it with other output-changing
    work, not piecemeal.

---

## Phase 3 — One grid↔sampler coordinate convention

The package has three conversions for the same mapping: corner-aligned
`x/(w−1)·(sw−1)` (`GridCellActionWriteFilterToLayer`, `CellMatcherLayerBitMaskTest`,
`ContinuousGridCellActionSetTerrainHeight`, `populateSampler2DFromCellFilter`),
ratio `x·(sw/w)` (`AbstractCellFilterSampleGridLayer` → both sample-layer
filters), and PaintTerrain's bespoke shift. Writes and reads through different
conventions drift by up to a cell at the far edge whenever `resolution ≠ 1`,
and `/(w−1)` divides by zero on 1-wide grids.

- [x] **3.1 Write the mapping spec.** One short doc section (in this file or a
  `COORDINATES.md`): grid integer coordinates are cell centers; layer texel `t`
  covers grid `[t/r, (t+1)/r)`; conversion is the ratio form
  `texel = grid · resolution` (consistent with `GridDataLayer.resolution` and
  `GridData.computeScale`'s half-tile world offset). Define the out-of-bounds
  policy per operation class: *reads* clamp or return a defined exterior value
  (pick one), *matches* fail (no silent edge-clamping), *writes* are discarded
  with bounds checks.
- [x] **3.2 Implement typed helpers** (plain functions, no string keys):
  `grid_to_texel(x, resolution)`, `texel_to_grid(t, resolution)` or equivalent —
  small enough to inline, but one canonical definition to reference.
- [x] **3.3 Migrate the corner-convention sites** listed above to the ratio
  convention. Each migration gets a `resolution = 2` round-trip spec
  (write filter → layer → read via `CellFilterSampleLayerLinear`, assert values
  at interior and far-edge cells). `[output-changing]` for `resolution ≠ 1`
  layers (game currently uses resolution 1 — verify with a grep — so likely
  output-stable in practice).
- [x] **3.4 `CellMatcherLayerBitMaskTest`: out-of-bounds = no match.**
  Today OOB probes clamp to the border cell, so `CellMatcherGridPattern`
  patterns match at map edges against themselves. Make OOB return `false`.
  `[output-changing]` at map borders — likely fixes visible edge artifacts;
  verify against the sample generators.
- [x] **3.5 `MarkerNodeActionPaintTerrain` mapping rebuild** on top of 3.1–3.2:
  replace the `+ splat_to_grid` shift and hand-rolled region math with the
  helpers; keep the Phase-1 clamping test green; add a "paint a radius-r marker
  at center, splat 2× resolution → painted disc is centered and symmetric" spec.
- [x] **3.6 `ContinuousGridCellActionSetTerrainHeight` coverage check.** With the
  ratio convention, document that per-cell invocation (e.g. via
  `ThemeEngine.applyCellRules`) writes only the texels under the cell footprint;
  if the height sampler is ever denser than `2× grid`, callers must use
  `GridActionRuleSet`'s `resolution` parameter. Add an assert comparing sampler
  resolution to invocation resolution if cheap to thread through; otherwise doc.

---

## Phase 4 — One initialization protocol

Today: five signatures (`(grid, seed)`, `(grid, ecd, seed)`, `(seed, ecd, grid)`,
`(data, ecd)`, `(grid)`), three flag disciplines, `ensureInitialized` used at 3 of
~35 sites, `CellFilterCellMatcher` never sets its flag (re-initialized by every
parent), and no composite finalizes recursively (stale noise tables/caches on
reuse with a new grid/seed).

Staged to keep the library API stable until the deliberate break:

- [x] **4.1 (non-breaking) Base-class template within each hierarchy.**
  For `CellFilter`: children enumerate via an overridable
  `get children() { return EMPTY; }` on the base (Binary/Unary/Tertiary/complex
  filters override with their inputs); `ensureInitialized` becomes the only
  recursion driver (walks children, then calls the subclass hook); subclass
  `initialize` overrides lose all flag/child bookkeeping; `finalize` recurses the
  same way. Delete the ~15 hand-rolled `if (!x.initialized) x.initialize(...)`
  blocks inside the filter hierarchy. Fixes the `CellFilterCellMatcher` flag bug
  by construction. Same treatment inside `CellMatcher` (flagless today — give it
  the same base machinery or document statelessness as a requirement).
  Tests: shared-subtree DAG (one filter referenced by two parents) initialized
  once per generation; re-initialization after `finalize` with a different seed
  actually re-seeds (SimplexNoise spec: different seeds → different output after
  finalize+initialize).
- [x] **4.2 (breaking, one sweep) Signature unification.**
  Decision first: target signature `initialize(grid, ecd, seed)` everywhere
  (hierarchies that don't need `ecd` ignore it), or keep two sanctioned
  signatures (`(grid, seed)` and `(grid, ecd, seed)`) and only fix the outliers
  (`ContinuousGridCellAction`'s `(seed, ecd, grid)`, `Theme`/`CellProcessingRule*`'s
  `(seed, ecd, grid)`, `MarkerNodeEntityProcessor`'s missing seed).
  Recommendation: the two-signature option — smaller blast radius, still removes
  the argument-order traps. Sweep all subclasses + meep samples + `app/src/generator`
  call sites in one commit. Includes 2.8's `MarkerNodeEntityProcessor` seed.
- [x] **4.3 `AbstractCellFilterSampleGridLayer`**: throw the same friendly
  "Layer 'x' not found" error `GridLayerCellMatcher` throws instead of a
  `TypeError` on the next line.

---

## Phase 5 — Library architecture

- [x] **5.1 Invert the `src → samples` dependency.** (Do early — 1.3's test wants it.)
  `grid/generation/road/GridTaskGenerateRoads.js` imports `GridTags`,
  `MirGridLayers`, `matcher_tag_traversable`; `grid/generation/discrete/GridTaskConnectRooms.js`
  imports `matcher_tag_unoccupied`. A library must not hardcode one sample
  game's tag scheme. Make `traversable`/`modifiable`/`actions` **required**
  `from({...})` parameters (house rule: required params, not null-default
  sentinels). Update the two classes, the samples, and `app/src/generator`
  call sites. Roads also: `vertex` field is unused — remove or wire it; the
  unused `weightMap` argument passed to `buildPaths` goes too.
- [x] **5.2 One weighted-pick helper.** Extract the duplicated
  cumulative-weights + `binarySearchHighIndex` selection from
  `CellActionSelectWeightedRandom` and `MarkerNodeActionSelectWeighted` into a
  typed core function (e.g. `random_pick_weighted_index(random, weights, count)`
  over a caller-owned cumulative array). Both classes keep their typed public
  shape; total-weight-0 handled once (see 1.12). Keep the two element-wrapper
  classes — they're the typed API.
- [ ] **5.3 One grid BFS/Dijkstra core.** Four hand-rolled variants:
  `GridTaskBuildSourceDistanceMap`, `util/buildDistanceMapToObjective`,
  `GridTaskConnectRooms.fillConnectedArea` (a BinaryHeap used as a FIFO),
  roads' `tBuildPaths` loop. Extract one expansion routine with: multi-source
  seeding, traversable predicate, per-visit callback / termination predicate,
  and **mark-on-push** membership (BitSet) instead of `heap.contains` linear
  scans (O(n²) today). Migrate the four call sites; behavior must be
  output-stable (same expansion order — keep pop-order semantics identical;
  verify with the roads/connect-rooms e2e tests from Phase 1).
- [x] **5.4 `MarkerNode` position authority.** Nodes carry grid `position` and
  world `transform`, hand-synced; `MarkerNodeTransformerOffsetPosition` moves
  only grid, `…AddPositionYFromFilter` only world — after either, the other is
  stale. Decision: grid `position` (+ yaw + scale) is authoritative; world XZ is
  derived via `GridData.transform` at consumption time (entity placement,
  PaintTerrain); world Y remains free (terrain cling / Y-offset transformers).
  Concretely: `MarkerNodeActionEntityPlacement` recomputes world XZ from grid
  position before composing transforms, and `OffsetPosition` stops being a
  silent divergence point. Audit every `transform.position` reader in the
  package + game call sites before flipping. This is the largest semantic
  refactor in the plan — schedule last, behind a dedicated decision review.
- [ ] **5.5 Task-ordering mechanisms.** `GridTaskGenerator.dependencies` resolves
  by `indexOf` among direct siblings of one `GridTaskGroup` (cross-group deps
  throw "not found"), while `GridTaskSequence` is structural and *rejects*
  children with dependencies. Decision: (a) document the constraint and improve
  the error message (cheap), or (b) resolve dependencies via a build-context map
  (generator → task) threaded through nested `build` calls so cross-composite
  deps work. Recommendation: (b) is a contained change to `GridTaskGroup`/
  `GridTaskSequence.build` signatures (internal callers only) and removes the
  landmine; do it when a real cross-group dependency first appears, (a) now.
- [x] **5.6 Combinator hierarchies — explicit go/no-go.** Full unification
  (matchers as boolean-typed filters; generic marker predicates) would remove
  ~2 of the 4 And/Or/Not families and both bridge classes, but it is a breaking
  conceptual change to the library's public vocabulary, and ruling #1 limits how
  much boilerplate it can actually save. Default: **no-go for now.** Keep the
  four families; rely on 1.1's full truth-table specs (replicated for each
  family) to keep the hand-copied combinators honest. Revisit only alongside a
  major-version API pass.
- [x] **5.7 Non-functional stubs — finish or remove (decision, per ruling #2).**
  - `grid/generation/discrete/layer/GridTaskDistanceToMarkers.js` — `build`
    references an undefined `target` and returns `undefined`; crashes any
    caller via `GridTaskGroup`. No caller can use it as-is.
  - `grid/generation/grid/GridTaskGridAlignedNodeGenerator.js` — empty `build`
    returning `undefined`; same failure mode.
  - `GraphGenerationRule.js` / `GridGeneratorConfig.js` — inert data holders,
    harmless; keep unless a vocabulary cleanup happens.
  Options per stub: finish (BuildSourceDistanceMap covers DistanceToMarkers'
  apparent intent — maybe it's already superseded), or remove. Owner call.
- [x] **5.8 `GridTaskConnectRooms` / roads internal cleanups** (output-stable):
  `fillConnectedArea` → stack/queue + mark-on-push (via 5.3); drop the
  re-flood-of-everything per connected room if 5.3 makes incremental frontiers
  cheap (measure first — only worth it on large grids); `drawPath`'s
  constant `remap(0, n, 1, 1, i)` thickness scaffolding → either implement
  per-endpoint thickness or simplify to the constant.

---

## Testing & verification protocol

- **Where/how:** Jest from `H:/git/moh` with the root `jest.conf.json`.
  Black-box only: build a real `GridData` (+ real layers, markers, filters),
  run the task/filter/action to completion, assert observable state (layer
  contents, marker set, returned values). No mocks, no call-count assertions.
  Test-local *recording* subclasses of action/consumer base classes are fine —
  they observe behaviour through the public contract.
- **Determinism suite (new, cheap, high value):** for each task generator fixed
  in this plan, one spec asserting two runs with the same seed produce identical
  grid/layer/marker state, and (where meaningful) different seeds differ.
  This is the regression net for all `[output-changing]` work.
- **`[output-changing]` verification:** after each Phase-2/3 batch, run
  `SampleGenerator0` / `AlienGridGenerator` (and the combat generator) with a
  fixed seed and eyeball the result against pre-change captures. Batch
  output-changing merges so level-gen output shifts a known number of times,
  not once per commit.
- **Coverage debt:** the bugs found all lived in files with constructor-only or
  no specs (`MarkerNodeMatcher*`, roads, `GridTaskExecuteRuleTimes`,
  `ThemeEngine.applyNodes`, `PaintTerrain`, the CA). Every Phase-1 fix lands
  with its spec; don't backfill beyond that until the phases above are done.

## Explicitly rejected / out of scope (for the record)

- String-keyed operation factories (`CellFilterBinary.of('add', …)`) — rejected;
  typed verbosity preferred (ruling #1).
- Deleting unreferenced-but-working classes (`CellFilterAnd`, `CellFilterSobel`,
  `MarkerNodeEmitterGroup`, `NoopGridTaskGenerator`, etc.) — rejected; library
  surface (ruling #2). Bugs inside them are fixed instead (1.9, 1.10, 1.11).
- Any treatment of `prototype*.js` files — internal tooling, out of scope
  (ruling #3).
- Blanket replacement of module-static scratch pools — house style; assert
  guards instead (1.14).

---

## Execution log

**Phase 1 + 5.1 (2026-06-11)** — all items landed with specs, full generation
suite green (50 suites / 109 tests). Decisions taken during execution:

- 1.4: exhaustion keeps signalling `EndFailure` (current intent preserved);
  cells are now closed per attempt, so a rule can no longer fire twice on the
  same cell within one build.
- 1.12: the pre-existing spec pinned "single option with weight 0 still
  executes" — flipped per plan to "all-zero weights execute nothing".
- 1.14: guards added to `GridData.marker_collection` and `CellFilterCurvature`
  scratch only; `CellFilterLookupTable.sample` and
  `RoadConnectionNetwork.scratch_paths` run no user code between fill and read,
  so they cannot re-enter — left unguarded.
- 5.1: `GridTaskGenerateRoads` lost its unused `vertex` field and the dead
  `weightMap` argument; `GridTaskConnectRooms.drawDebugState` now imports the
  DOM helper lazily. Call sites updated: `SampleGenerator0` (meep),
  `generateGameplay` (moh).
- New test helper: `test_support/executeTaskTreeSync.js` — runs a Task/TaskGroup
  tree synchronously, respecting dependencies.

**Phase 2 (2026-06-11)** — semantic fixes landed, suite green (51 suites / 122
tests). Decisions taken:

- 2.1: OOB neighbours = dead (in this CA, alive = open space, so OOB-dead seals
  borders); margins not frozen (same creep semantics as before); state is
  double-buffered via front/back Uint8Arrays in GridTaskCellularAutomata.
- 2.3: sigma = (samples-1)/6 in kernel-index units (kernel covers +-3 sigma).
- 2.4: shared computeFilterSurfaceNormal helper; convention: z up, surface
  rising along +x tilts normal towards -x; dome = positive curvature.
- 2.5: one roll per (cell x rule x offset); rotation scan picks orientation
  only. RNG is now drawn before matching, so draw sequences shift everywhere.
- 2.6: aggregator normalize switched L2 -> L1 (terrain shader divides by weight
  sum, so this is output-stable); zero-sum guard added; influence divisor
  removed (proven no-op); resample uses texel-center mapping.
- 2.7: estimation reads action.size directly; processArea builds nodes only
  AFTER the overlap rejection (the spec caught this second consumption site);
  testNodeCollisions uses new buildBaseNode (geometry only); purity
  expectations documented on MarkerNodeTransformer.
- 2.8: ArrayIteratorRandom seeding landed in 1.13; groupId documented as
  opaque; derive_seed helper DEFERRED — applying it would shift all generation
  output for marginal gain, revisit alongside a future intentional regen-shift;
  EntityProcessor seed plumbed in Phase 4.

**Phase 3 (2026-06-11)** — coordinate convention unified, suite green (52
suites / 128 tests). Decisions taken:

- Convention doc lives in `COORDINATES.md`; canonical helpers in `grid/coords/`.
- DEVIATION from 3.3: `ContinuousGridCellActionSetTerrainHeight` was NOT
  migrated — the terrain heightmap is the terrain renderer's vertex-aligned
  domain and its corner mapping must match the terrain mesh, not grid layers.
  Documented on the class and in COORDINATES.md (covers 3.6 as well).
- 3.4: out-of-bounds layer probes now fail the match (border patterns stop
  matching against themselves) — output-changing at map borders.
- 3.5: PaintTerrain's one-texel center shift removed; painted discs are now
  centered on the marker (verified at 1x and 2x splat resolution).

**Phase 4 (2026-06-11)** — init protocol unified, suite green (53 suites / 130
tests). Decisions taken:

- 4.1: `CellFilter` base drives recursion via an overridable `get children()`;
  `ensureInitialized` is the guarded entry, `initialize` the unconditional
  subclass hook (super first), `finalize` recurses. All hand-rolled
  `if (!x.initialized) x.initialize(...)` sites replaced with
  `ensureInitialized`. `CellFilterCellMatcher`'s missing flag fixed by
  construction. `CellMatcher` stays flagless: idempotence documented as the
  contract.
- 4.2: went with the two-sanctioned-signatures option — `(grid, seed)` and
  `(grid, ecd, seed)`. Outliers flipped: `ContinuousGridCellAction` family
  (+ `ensureInitialized` added to that base), `Theme`, `CellProcessingRule(Set)`,
  `ThemeEngine.initializeThemes`. `MarkerNodeEntityProcessor.initialize` now
  receives `seed`; `RandomRotation` finally varies with the world seed
  (output-changing, was the point). `SetTerrainObstacle`'s threshold filter
  is initialized via the same sweep.
- New spec `filtering/CellFilter.spec.js` pins DAG init-once and
  finalize-then-reseed.

**Phase 5 (2026-06-11)** — suite green (53 suites / 130 tests). Status per item:

- 5.2 DONE: `core/math/random/random_pick_weighted_index.js` — both weighted
  selects rewired; zero-total guard lives in one place; RNG draw order
  unchanged (helper does not consume the RNG when total weight is 0, same as
  the phase-1 guards).
- 5.3 PARTIAL (deliberate): mark-on-push membership BitSets replace every
  linear `heap.contains` scan (BuildSourceDistanceMap, roads buildPaths), and
  `fillConnectedArea` is a plain stack flood-fill instead of a BinaryHeap used
  as a FIFO (order-independent result, verified by the e2e specs). The full
  one-core-for-all extraction was NOT done: the four loops differ in exactly
  the parts that matter (incremental task pumping, objective early-exit,
  per-neighbor quadtree probes with early returns) — a shared core would be
  callback soup costlier than the duplication. Revisit if a fifth variant
  appears.
- 5.4 RESOLVED (owner framing, 2026-06-11): `.position` is the virtual 2D-domain
  location, `transform` is concrete world-space presentation — two domains, not
  duplication; the "pick one authority" sweep is dead. Under that framing the
  only defect was `MarkerNodeTransformerOffsetPosition` relocating the marker
  in the virtual domain only; it now maintains the planar correspondence
  (output-changing in the intended direction: offset wall segments / base
  connectors finally move in world space — GenerateWalls, place_bases).
  Domain contract documented on the `MarkerNode` fields.
- 5.5 option (a) DONE: GridTaskGroup's dependency-resolution error now explains
  the sibling-only constraint and points at GridTaskSequence. Option (b)
  (build-context resolution map) deferred until a real cross-group dependency
  appears.
- 5.6 NO-GO confirmed: combinator hierarchies stay; truth-table specs (phase 1)
  are the guard against future copy-paste drift.
- 5.7 RESOLVED (owner: "if something is a stub - remove it"):
  `GridTaskDistanceToMarkers` and `GridTaskGridAlignedNodeGenerator` removed.
- 5.8 DONE: `drawPath` constant-thickness scaffolding simplified away (output
  identical: thickness was constant 1); the per-room re-flood optimization
  skipped per the item's "measure first".

**Coverage pass (2026-06-11)** — package-wide first-principles black-box specs:
117 suites / 234 tests (from 54 / 132). Constructor-only specs replaced with
behavioural ones. Found and fixed in the process: `MarkerNode.copy` dropped
`priority` — every cloning transformer silently reset a node's processing
priority (applyNodes sorts on it).

Deliberately NOT given dedicated specs: abstract bases and one-line data
holders; debug tooling (`visualizeMarkers`, `visualise_filters_as_grid`,
LogToConsole actions/filters, `GridCellActionDebugBreak` — contains a
`debugger` statement); `MarkerNodeEntityProcessorClingToTerrain` and
`ThemeEngine.applyTerrainThemes` (both need the built terrain tile/asset
pipeline — integration tests, not headless unit territory);
`GridTaskActionRuleSet` (one-line delegation, covered through
GridActionRuleSet's own specs).

VISUAL VERIFICATION PENDING: phases 1–4 change generated output (fixed seeds
produce different levels). Run SampleGenerator0 / AlienGridGenerator and the
combat generator against pre-change captures before shipping. Phase 5 and the
coverage pass are output-stable (the priority fix only affects nodes that were
losing priority through transformer clones).
