diff --git a/docs/noise/cliffs-NOTES.md b/docs/noise/cliffs-NOTES.md index 889974b..e5892fb 100644 --- a/docs/noise/cliffs-NOTES.md +++ b/docs/noise/cliffs-NOTES.md @@ -378,9 +378,12 @@ Fields exact, rule structurally confirmed, precision irrelevant, smoothing confirmed - and 12.5% of shared cells still carry the wrong crossings. The candidates that survive: -1. **`fixImpossibleCells` in detail.** It only accounts for 1.8 points of the - 12.5 as ported, but "our sweep differs subtly from the game's" is not excluded - by that - a different sweep could be both closer AND worth more. +1. ~~**`fixImpossibleCells` in detail.**~~ **Closed 2026-08-01** - see the next + section. All 24 clear orders were swept (the ported `L, T, R, B` wins) and, + decisively, the pass finds only 35 illegal cells and clears 59 edges across + all three regions. It is too small by a factor of five to be the cause. The + original wording here - "a different sweep could be both closer AND worth + more" - was right to keep it open and wrong about the size. 2. ~~**The choice of expression, which no substitution can test.**~~ **Closed from the data, 2026-07-30.** The worry was real - the corner-field fixtures capture `vulcanus_elevation` and `cliffiness_basic` **by name**, so if a @@ -404,6 +407,119 @@ candidates that survive: different field - so this was a live way to be wrong, in the same shape as the `cliff_smoothing` default that cost issue #18 two months. +### Nine more causes falsified, 2026-08-01 - and candidate 1 above is now CLOSED + +The list above left `fixImpossibleCells` as the surviving candidate. It is not the +cause, and neither is anything else that can be reached by varying the port. + +**The harness, because it is reusable and it is what makes these cheap.** The +smoothing is a transform on the elevation field, so a candidate smoothing can be +*pre-applied to the field* and the placement then run with `smoothing: 0`. That +runs the SHIPPING rule over a candidate transform, needs no edit to +`cliffPlacement.ts`, and scores in ~1.5s for all three regions because one field +cache is shared across every candidate. The control arm reproduces +**1400 matched / 175 wrong** to the digit, which is what licenses the rest. + +**1. The smoothing knot geometry is right, and it is a sharp optimum in four +dimensions.** Sweeping `(span, clamp, anchor offset, blend s)`: + +| variation | wrong / matched | | +| --- | --- | --- | +| **shipping: span 4, clamp 7, offset 0, s=1** | **175 / 1400** | **12.5%** | +| clamp 8 (the "clean" global bilerp) | 378 / 1124 | 33.6% | +| clamp 6 | 485 / 1189 | 40.8% | +| anchor offset 1..7 | 523-578 | 51-72% | +| s = 0.9 / 0.75 / 0.5 / 0 | 238 / 371 / 530 / 705 | 17.6-62.8% | +| span 8 / span 2 | 367 / 572 | 64.8% / 55.1% | + +Both axes were also swept independently (`clampX` x `clampY`, `offsetX` x +`offsetY`): the minimum is at (7,7) and (0,0), not on any off-diagonal. **The +odd clamp-to-7 asymmetry that reads like a misread is 2.7x better than the clean +reading** - so the disassembly is now corroborated by something other than +itself, which is exactly what that section needed. + +**2. Cliffiness stays raw.** Smoothing it too: 279/1397 = 20.0%. + +**3. Linear interpolation, not a curve.** smoothstep 29.3%, smootherstep 37.3%, +nearest 61.8%, sqrt 50.5%, square 53.9% - against linear's 14.3% in the same +(fix-sweep-free) harness. + +**4. The band constants are a sharp optimum too.** `cliff_elevation_0` swept +64..76 bottoms exactly at **70** (12.5%, rising to 25.7% at 65 and 23.1% at 76); +`cliff_elevation_interval` bottoms exactly at **120**. The elevation range over +the sampled corners is -62.3 .. 1226.3, so ~10 bands are genuinely in play and +the interval is not inert. + +**5. No structural variant of `crossesCliff` helps.** Dropping the negative- +elevation gate 12.9%, gating on raw elevation instead 12.5%, non-strict +comparison 12.5%, dropping the cliffiness gate 22.5%, anchoring the band on +`min(a,b)` places nothing at all. The "different band" formulation scores +**exactly** 175 - it is provably identical to the max-anchored one except when +`max(a,b)` sits exactly on a boundary. + +**6. `fixImpossibleCells` cannot be the cause, by size.** All 24 edge-clearing +priorities were swept: the ported **`L, T, R, B` is the best of all 24** (175, +against 181-217). But the decisive number is that across all three regions the +pass finds only **35 illegal cells and clears 59 edges**. Identical fields plus +an identical rule give identical pre-fix codes, so a pass that touches ~35 cells +cannot produce 175 wrong ones. This closes candidate 1. + +**7. The fields were compared as VALUES for the first time, not as outcomes.** +Every previous exoneration ran the substitution and asserted the placement did +not move. That is weaker than it sounds. Comparing our field directly against +`oracle-vulcanus-cliff-corner-fields-entity-regions` over all 12,675 corners: + +| | median | max | +| --- | --- | --- | +| elevation \|ours - game\| | 2.7e-4 | **4.8e-2** | +| cliffiness \|ours - game\| | 0 | 6.4e-6 | + +The corrections the failures need are **~3.6 elevation units** (below), four +orders of magnitude larger. The fields are right, and now that is measured +rather than inferred from a null result. + +**8. The packing, the lattice and the table are cleared from the fixture alone.** +See `test/cliffEdgeConsistency.spec.ts`: the game's own adjacent cliffs agree on +**every** shared edge (Nauvis 147 h + 166 v, Vulcanus 805 h + 834 v, zero +mismatches), and all 1569 `cliff-vulcanus` land exactly on `(cx*4+2, cy*4+2.5)` +while exactly the 8 `crater-cliff`s do not. + +**9. The failure shape says "diffuse", not "structural".** The cliffiness gate +blocks **zero** of the edges the game crossed; 141 of 156 are `sameSide` (our two +corner elevations do not straddle a band) and 13 are `band the 8-bit crossing code, inverting the placing half of the table. */ +const inverseTable = (swap?: readonly [number, number]): Map => { + const names = [...CLIFF_ORIENTATION_NAMES]; + if (swap !== undefined) { + const [a, b] = swap; + [names[a], names[b]] = [names[b], names[a]]; + } + const m = new Map(); + for (const [codeStr, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION)) + m.set(names[id], Number(codeStr)); + return m; +}; + +const key = (x: number, y: number): string => `${String(x)},${String(y)}`; + +interface Tally { + onLattice: number; + offLattice: number; + hPairs: number; + vPairs: number; + mismatches: string[]; +} + +/** + * Rebuild each case's cliff cells from the game's own `cliff_orientation`, then + * check that every two adjacent cells agree on the edge they share. + * + * Cases are tallied **separately** and never merged. Nauvis's cases are the same + * region at different seeds, so a shared map would collide two unrelated cliffs + * at one cell and invent mismatches - measured, it invents four. + */ +const tally = (cases: { readonly cliffs: readonly Cliff[] }[], only?: string): Tally => { + const table = inverseTable(); + const out: Tally = { onLattice: 0, offLattice: 0, hPairs: 0, vPairs: 0, mismatches: [] }; + for (const c of cases) { + const cells = new Map(); + for (const p of c.cliffs) { + if (only !== undefined && p.name !== only) continue; + const cx = Math.round((p.x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE); + const cy = Math.round((p.y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE); + if ( + Math.abs(cx * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X - p.x) > 1e-9 || + Math.abs(cy * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y - p.y) > 1e-9 + ) { + out.offLattice++; + continue; + } + out.onLattice++; + const code = table.get(p.orientation); + if (code !== undefined) cells.set(key(cx, cy), code); + } + for (const [k, code] of cells) { + const [cx, cy] = k.split(",").map(Number); + const right = cells.get(key(cx + 1, cy)); + if (right !== undefined) { + out.hPairs++; + // R of the left cell is L of the right cell: the same vertical edge. + if (((code >> 4) & 3) !== ((right >> 6) & 3)) out.mismatches.push(`h ${k}`); + } + const below = cells.get(key(cx, cy + 1)); + if (below !== undefined) { + out.vPairs++; + // B of the upper cell is T of the lower cell. + if ((code & 3) !== ((below >> 2) & 3)) out.mismatches.push(`v ${k}`); + } + } + } + return out; +}; + +/** + * **The game's own cliffs, checked against each other** (issue #18). + * + * Every other cliff oracle in this repo compares the port's output against the + * game's. This one uses *no field, no rule and no port output at all*: it takes + * the game's `cliff_orientation` for each cliff, inverts + * `CLIFF_CODE_TO_ORIENTATION` to recover the 8-bit crossing code the engine must + * have held, and asserts that two cliffs sitting in adjacent cells agree about + * the edge between them - the engine stores one value per edge, so they must. + * + * What that pins, which nothing else did: + * + * - **The code packing** (`L R T B`, two bits each, in that order). A transposed + * or reordered packing makes neighbours disagree immediately. + * - **The position -> cell mapping** (`cx * 4 + 2`, `cy * 4 + 2.5`). Every real + * cliff must land exactly on that lattice. + * - **The orientation table**, in the inverse direction. + * + * It matters because these three were the last unmeasured links in the Vulcanus + * orientation residual. `cliffOrientationOracle.spec.ts` shows Nauvis matching + * 334/334, which argues the table is right; but Nauvis exercises neither + * `cliff_smoothing` nor a continuous cliffiness field, so "Nauvis is exact" + * could not by itself clear the packing for Vulcanus. This does, and it does it + * from the fixture alone - measured 2026-08-01, both planets agree on **every** + * shared edge. + */ +describe("the game's cliffs agree with each other on shared edges", () => { + const n = tally(nauvis.cases); + const v = tally(vulcanus.cases, "cliff-vulcanus"); + + it("puts every real cliff exactly on the 4-tile placement lattice", () => { + // Measured 2026-08-01. Pinned exactly: these are the full fixture contents, + // so a shrinking comparison set cannot pass unnoticed. + expect(n.onLattice).toBe(334); + expect(n.offLattice).toBe(0); + expect(v.onLattice).toBe(1569); + expect(v.offLattice).toBe(0); + }); + + it("finds the 8 crater-cliffs OFF the lattice, which is what makes that test sharp", () => { + // `crater-cliff` is placed by the entity autoplace with jitter, not by + // `generateCliffs`, so its positions are fractional. Without this the + // lattice assertion above could pass vacuously against a rounding that + // accepted anything. + const all = tally(vulcanus.cases); + expect(all.offLattice).toBe(8); + expect(all.onLattice).toBe(1569); + }); + + it("agrees on every shared edge, on both planets", () => { + expect(n.mismatches).toEqual([]); + expect(v.mismatches).toEqual([]); + // Non-vacuity: without these the loop would pass by comparing nothing. + // Measured 2026-08-01. + expect(n.hPairs).toBe(147); + expect(n.vPairs).toBe(166); + expect(v.hPairs).toBe(805); + expect(v.vPairs).toBe(834); + }); + + it("FAILS when the orientation table is corrupted - the guard discriminates", () => { + // Swap two orientations in the inverse table and the same data must stop + // being self-consistent. Without this, a check that trivially passed + // (say, because every code decoded to the same edges) would look like + // confirmation. `north-to-south` (5) and `east-to-west` (7) are both common + // in the fixtures, so the swap is guaranteed to be exercised. + const table = inverseTable([5, 7]); + let pairs = 0; + let bad = 0; + for (const c of vulcanus.cases) { + const cells = new Map(); + for (const p of c.cliffs) { + if (p.name !== "cliff-vulcanus") continue; + const cx = Math.round((p.x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE); + const cy = Math.round((p.y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE); + const code = table.get(p.orientation); + if (code !== undefined) cells.set(key(cx, cy), code); + } + for (const [k, code] of cells) { + const [cx, cy] = k.split(",").map(Number); + const right = cells.get(key(cx + 1, cy)); + if (right === undefined) continue; + pairs++; + if (((code >> 4) & 3) !== ((right >> 6) & 3)) bad++; + } + } + expect(pairs).toBe(805); + expect(bad).toBeGreaterThan(0); + }); +});