From 461eb3c8fd361521fa76a226473c64dbe90a950a Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 2 Aug 2026 17:37:16 -0700 Subject: [PATCH] test(cliffs): the orientation residual IS part of the over-placement (#84) The wrong orientations and the surplus cells have been tracked as two defects. They are one. `placedCells` builds one edge register per chunk, so `v[cy][cx]` is cell `cx`'s left edge and cell `cx-1`'s right edge - the same array slot, not two equal values. A spurious crossing therefore cannot sit inside one cell: it corrupts the orientation of the real cell on one side and manufactures a cliff the game never placed on the other. Over the three oracle regions, rejections off: 37 wrong orientations, **0** whose disputed-edge neighbour the game places, 34 distinct phantom neighbours, and **34 of 34** are cells the port emits - i.e. surplus. On the shipping path this owns 12 of the 25 surplus cells, and at [0,0] it owns every surplus cell there is (2 of 2). That does not change the design of the blocked grid-4 cliff-elevation capture, it changes what it is worth: it was being weighed against "1.6% of cells carry a wrong orientation", which reads like a rounding-error chase. Ruled out on the way, each with a discriminating control: - No gate in `crossesCliff` is marginal on the disputed edges, scored against every crossing edge of every matched cell as control. - The asymmetric smoothing knot lattice (in-chunk knots at 0, 4 and 7, `hi` clamped to CHUNK_CORNERS-1) is not a misreading. Knots every 4 corners globally is 8x worse - 312 wrong against 37. The disassembly reading now has a measurement behind it. The new spec was checked for vacuity by pointing the neighbour lookup at the wrong axis: 4 of its 5 tests fail. Item of #84; the issue stays open. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FpXiK2MPqPFVtm5CMPNXR2 --- docs/noise/vulcanus-cliffs-NOTES.md | 66 ++++++++ test/cliffPhantomNeighbour.spec.ts | 254 ++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 test/cliffPhantomNeighbour.spec.ts diff --git a/docs/noise/vulcanus-cliffs-NOTES.md b/docs/noise/vulcanus-cliffs-NOTES.md index cd63c5a1..a016f79c 100644 --- a/docs/noise/vulcanus-cliffs-NOTES.md +++ b/docs/noise/vulcanus-cliffs-NOTES.md @@ -1757,3 +1757,69 @@ of the 4-grid program, e.g. a mod that publishes `vulcanus_basalt_lakes_multisample` at grid 4 into a readable tile property. Until that exists, "the fields are exonerated" cannot be said of the channel that matters - and #93's exoneration rested on a substitution in the tile channel. + +## The residual and the OVER-PLACEMENT are one defect (2026-08-03, #84) + +Everything above treats two numbers as separate problems: the wrong orientations +(33 on the shipping path) and the surplus cells (25). They are the same defect, +and the reason is structural rather than statistical. + +`placedCells` builds **one edge register per chunk**. `v[cy][cx]` is cell `cx`'s +left edge and cell `cx-1`'s right edge - not two equal values, the same array +slot. So a spurious crossing can never sit inside one cell. It corrupts the +orientation of the real cell on one side and manufactures a cliff the game never +placed on the other. + +Measured over all three oracle regions, rejections off so the geometry is not +masked: + +| | | +| --- | --- | +| matched cells | 1531 | +| wrong orientations | 37 | +| of those whose disputed-edge neighbour the GAME places | **0** | +| distinct phantom neighbours | 34 | +| of those the PORT places, i.e. that are surplus cells | **34 of 34** | + +Not one of the 37 has a neighbour the game agrees about, and not one phantom +fails to be surplus. On the shipping path: + +| region | matched | wrong | surplus | surplus that ARE phantoms | +| --- | --- | --- | --- | --- | +| `[0,0]` | 281 | 5 | 2 | **2 of 2** | +| `[1500,1500]` | 858 | 25 | 22 | 10 of 22 | +| `[-1200,800]` | 386 | 3 | 1 | 0 of 1 | + +**At `[0,0]` the spurious crossings are the whole of the over-placement.** And +the reason 33 wrong cells do not imply 33 surplus is that the lava and ore +rejections already remove 19 of the phantoms - the rejection hides the phantom +while leaving the neighbouring cell's orientation wrong, which is exactly how the +two counts drifted apart and came to be read as unrelated mechanisms. + +What this changes is the **value** of the blocked oracle capture above, not its +design. It was being weighed against "33 wrong orientations, ~1.6% of cells", +which reads like a rounding-error chase; it is also worth 12 of the 25 surplus +cells, and all of `[0,0]`'s. `test/cliffPhantomNeighbour.spec.ts`. + +### Ruled out on the way, each with a discriminating control + +- **No gate in `crossesCliff` is marginal on the disputed edges.** Measured + against every crossing edge of every matched cell as control: cliffiness gap + above the `> 0.5` threshold (disputed min 6.8e-3 vs control 1.0e-3), the + `a < 0 || b < 0` early-out (disputed min elevation 15.6, nowhere near zero) and + `boundary < e0` (disputed min 2.73 above `e0`). The disputed edges are not + sitting on any cliff edge of the rule. This corroborates the band-margin result + above by a different route. +- **The asymmetric smoothing knot lattice is not a misreading.** The port puts + in-chunk knots at corner indices **0, 4 and 7** because `hi` is clamped to + `CHUNK_CORNERS - 1`, which is odd enough to look like an off-by-one. Scoring + the natural alternative - knots every 4 corners globally, i.e. 0, 4, 8 - it is + **8x worse**: 312 wrong orientations against 37, matched collapsing 1531 -> 1173 + and missing 38 -> 396. Raw (`s = 0`) is worse still at 677. The disassembly + reading now has a measurement behind it, not just a careful read. +- **`cliffiness_basic` stays exonerated, and the exoneration is sound.** It + contains no `multisample` (the channel audit, #87, found `multisample` in + exactly one expression in all of factorio-data), so unlike elevation it has no + grid-4 variant to be captured in the wrong channel. Its gate test scores the + binary the consumer reads over 24,960 edges with hit counts and both outcomes + asserted - 0 flips. diff --git a/test/cliffPhantomNeighbour.spec.ts b/test/cliffPhantomNeighbour.spec.ts new file mode 100644 index 00000000..94e1b9d7 --- /dev/null +++ b/test/cliffPhantomNeighbour.spec.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vite-plus/test"; + +import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; +import { + CLIFF_CODE_TO_ORIENTATION, + CLIFF_GRID_SIZE, + CLIFF_ORIENTATION_NAMES, +} from "../src/noise/cliffs/cliffCatalog"; +import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; +import { + VULCANUS_CLIFF_ELEVATION_0, + VULCANUS_CLIFF_ELEVATION_INTERVAL, + VULCANUS_CLIFF_SMOOTHING, + makeVulcanusCliffFields, +} from "../src/noise/cliffs/vulcanusCliffFields"; +import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; +import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; +import { buildResources } from "../src/noise/preview/renderVulcanusResources"; +import { makeVulcanusTileResolver } from "../src/noise/tiles/vulcanusCatalog"; +import { withCtxDefaults } from "../src/noise/eval/ctx"; + +const key = (x: number, y: number): string => `${String(x)},${String(y)}`; + +/** code = (enc(L)<<6)|(enc(R)<<4)|(enc(T)<<2)|enc(B); enc: 0->0, +1->1, -1->3. */ +const edgesOf = (code: number): number[] => [ + (code >> 6) & 3, + (code >> 4) & 3, + (code >> 2) & 3, + code & 3, +]; + +/** + * Per edge index (L, R, T, B): the world offset to the cell that SHARES it. + * + * `placedCells` builds one edge register per chunk - `v[cy][cx]` is cell `cx`'s + * left edge and cell `cx-1`'s right edge, the same array slot - so two adjacent + * cells do not merely agree about the edge between them, they read the identical + * value. That is what makes the test below a test and not a coincidence hunt. + */ +const ACROSS: readonly (readonly [number, number])[] = [ + [-CLIFF_GRID_SIZE, 0], + [CLIFF_GRID_SIZE, 0], + [0, -CLIFF_GRID_SIZE], + [0, CLIFF_GRID_SIZE], +]; + +const codeForOrientation = new Map(); +for (const [c, id] of Object.entries(CLIFF_CODE_TO_ORIENTATION)) + codeForOrientation.set(id, Number(c)); +const nameToId = new Map(CLIFF_ORIENTATION_NAMES.map((n, i) => [n, i])); + +/** The game's orientation name -> the cell code that produces it (a bijection). */ +const gameCodeOf = (orientation: string): number | undefined => { + const id = nameToId.get(orientation); + return id === undefined ? undefined : codeForOrientation.get(id); +}; + +interface Ent { + x: number; + y: number; + name: string; + orientation: string; +} +interface Case { + region: { x0: number; y0: number; x1: number; y1: number }; + cliffs: Ent[]; +} + +const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; +const ctx = withCtxDefaults(INPUT); +const fields = makeVulcanusCliffFields(ctx); +const tileAt = makeVulcanusTileResolver(INPUT); +const oreRejects = makeVulcanusOreRejection(buildResources(ctx), ctx.vulcanusResourceControls); + +const place = ( + r: Case["region"], + withRejections: boolean, +): { x: number; y: number; code: number }[] => + makeCliffPlacementFromFields(fields, { + elevation0: VULCANUS_CLIFF_ELEVATION_0, + interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, + smoothing: VULCANUS_CLIFF_SMOOTHING, + tileCollides: withRejections + ? (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name) + : undefined, + cellRejects: withRejections ? oreRejects : undefined, + }).placedCells(r.x0, r.y0, r.x1, r.y1); + +interface Scored { + matched: number; + /** Cells the game also places, where our orientation differs. */ + wrong: number; + /** Cells we place that the game does not. */ + surplus: string[]; + /** Distinct neighbours across a disputed edge. */ + phantoms: Set; + /** Disputed edges whose neighbour the GAME places. Expected: none. */ + phantomPlacedByGame: number; +} + +const score = (c: Case, withRejections: boolean): Scored => { + const ours = new Map(place(c.region, withRejections).map((p) => [key(p.x, p.y), p.code])); + const game = new Map(); + for (const e of c.cliffs) if (e.name === "cliff-vulcanus") game.set(key(e.x, e.y), e.orientation); + + const phantoms = new Set(); + let matched = 0; + let wrong = 0; + let phantomPlacedByGame = 0; + for (const [k, ourCode] of ours) { + const want = game.get(k); + if (want === undefined) continue; + matched++; + const gameCode = gameCodeOf(want); + if (gameCode === undefined || gameCode === ourCode) continue; + wrong++; + const a = edgesOf(ourCode); + const b = edgesOf(gameCode); + const [xs, ys] = k.split(","); + for (let i = 0; i < 4; i++) { + if (a[i] === b[i]) continue; + const nk = key(Number(xs) + ACROSS[i][0], Number(ys) + ACROSS[i][1]); + phantoms.add(nk); + if (game.has(nk)) phantomPlacedByGame++; + } + } + return { + matched, + wrong, + surplus: [...ours.keys()].filter((k) => !game.has(k)), + phantoms, + phantomPlacedByGame, + }; +}; + +/** + * **The orientation residual and the over-placement are ONE defect** (issue #84). + * + * `cliffOrientationResidual.spec.ts` pins the residual's shape - every wrong cell + * differs from the game in exactly one edge, and always by finding a crossing the + * game does not - and `cliffOrientationMargin.spec.ts` rules out a boundary tie. + * Both treat the wrong orientations as their own defect, separate from the + * surplus cells counted in `cliffOreExclusion.spec.ts`. **They are not separate.** + * + * A cell's four edges are shared with its four neighbours - literally the same + * slot in the chunk's edge register, see `ACROSS` above - so a spurious crossing + * is never confined to one cell. It corrupts the orientation of the real cell on + * one side AND, on the other, manufactures a whole cliff the game never placed. + * Measured over all three oracle regions, without the rejections so the geometry + * is not masked: + * + * | | | + * | --- | --- | + * | matched cells | 1531 | + * | wrong orientations | 37 | + * | of those whose disputed-edge neighbour the GAME places | **0** | + * | distinct phantom neighbours | 34 | + * | of those the PORT places (i.e. that are surplus cells) | **34 of 34** | + * + * Not one of the 37 has a neighbour the game agrees about, and not one phantom + * fails to be a surplus cell. So the residual is not a cosmetic orientation + * mismatch to be chased after the placement is right - it IS part of the + * placement error, and one root cause retires both. + * + * **Why this reframes the hunt.** The open lead is the grid-4 cliff-elevation + * channel, which has no per-corner oracle (see `cliffOrientationMargin.spec.ts`). + * The value of capturing it was previously scored against 33 wrong orientations - + * about 1.6% of cells, easy to read as a rounding-error chase. It is worth more + * than that: on the shipping path it also owns 12 of the 25 surplus cells, and at + * `[0,0]` it owns **every** surplus cell there is. + */ +describe("the wrong orientations and the surplus cells are the same defect", () => { + const bare = (entities.cases as unknown as Case[]).map((c) => score(c, false)); + + it("compares a real population, not a handful", () => { + // Non-vacuity. If the residual is ever fixed these two lines are what will + // fail, and the correct response is to delete this file's premise, not to + // relax them - every assertion below is vacuous at `wrong === 0`. + expect(bare.reduce((n, s) => n + s.matched, 0)).toBeGreaterThan(1500); + expect(bare.reduce((n, s) => n + s.wrong, 0)).toBeGreaterThan(0); + }, 120000); + + it("never has the game placing the neighbour across the disputed edge", () => { + for (const s of bare) expect(s.phantomPlacedByGame).toBe(0); + }, 120000); + + /** + * The other half, and the one that makes it a shared defect rather than a + * shared symptom: every phantom is a cell the port really does emit. A + * disputed edge that produced no cliff on either side would be a discrepancy + * with no cost. + */ + it("makes every phantom neighbour a surplus cell of our own", () => { + let phantoms = 0; + for (const [i, s] of bare.entries()) { + const surplus = new Set(s.surplus); + for (const p of s.phantoms) { + phantoms++; + expect({ region: i, cell: p, surplus: surplus.has(p) }).toEqual({ + region: i, + cell: p, + surplus: true, + }); + } + } + // Measured 34 distinct phantoms behind 37 wrong cells - a few are shared, + // where one spurious crossing sits between two cells the game both places. + expect(phantoms).toBe(34); + }, 120000); +}); + +/** + * **What that costs on the path the renderer actually runs.** + * + * The bare arm above is the right control for the geometry - the lava and ore + * rejections drop cells for reasons unrelated to the crossings, and they drop + * phantoms and honest cliffs alike. But the arm that matters for accuracy is the + * one `renderVulcanusCliffs` runs, and the split there is worth pinning because + * it is not obvious from the bare numbers: + * + * | region | matched | wrong | surplus | phantoms | surplus that ARE phantoms | + * | --- | --- | --- | --- | --- | --- | + * | `[0,0]` | 281 | 5 | 2 | 5 | **2 of 2** | + * | `[1500,1500]` | 858 | 25 | 22 | 23 | 10 of 22 | + * | `[-1200,800]` | 386 | 3 | 1 | 3 | 0 of 1 | + * + * Two things follow. **At `[0,0]` the spurious crossings are the whole of the + * over-placement** - fix them and that region is exact. And the reason 33 wrong + * cells do not imply 33 surplus is that the rejections already remove 19 of the + * phantoms; the rejection hides the phantom while leaving the neighbouring cell's + * orientation wrong, which is why the two counts drifted apart and were read as + * unrelated in the first place. + */ +describe("the same defect, on the shipping path", () => { + const shipped = (entities.cases as unknown as Case[]).map((c) => score(c, true)); + + it("owns every surplus cell at [0,0]", () => { + const s = shipped[0]; + expect(s.surplus.length).toBeGreaterThan(0); + for (const k of s.surplus) expect(s.phantoms.has(k)).toBe(true); + }, 120000); + + it("owns a substantial minority of the surplus overall", () => { + const surplus = shipped.reduce((n, s) => n + s.surplus.length, 0); + const explained = shipped.reduce( + (n, s) => n + s.surplus.filter((k) => s.phantoms.has(k)).length, + 0, + ); + // Measured 12 of 25. Bounds rather than equalities: a fix should move both + // down, and this file should not have to be edited to let it. + expect(surplus).toBeLessThanOrEqual(25); + expect(explained).toBeGreaterThanOrEqual(Math.min(12, surplus)); + }, 120000); +});