diff --git a/docs/noise/vulcanus-cliffs-NOTES.md b/docs/noise/vulcanus-cliffs-NOTES.md index c42d2681..5d18a66b 100644 --- a/docs/noise/vulcanus-cliffs-NOTES.md +++ b/docs/noise/vulcanus-cliffs-NOTES.md @@ -2127,3 +2127,69 @@ smoothing, the repair, and - since #108 - the STAGE. What remains is which cells the game refuses, and the honest statement is that the list is open: the same trap #106 fell into is available here, so the next candidate needs a positive measurement rather than promotion by elimination. + +## The ore rule, scored against the lever - and the cascade refuted (2026-08-03, #84) + +`oracle-vulcanus-cliff-ore-direction` re-ran `[1500,1500]` with the resources +switched off through `autoplace_controls`, so the ore's effect is a known SET of +cells rather than an inference. Scoring the port's predicate against it gives a +precision and a recall instead of a total to match. +`test/cliffOreCascade.spec.ts`. + +| | | +| --- | --- | +| game, resources ON / ALL OFF (in region) | 861 / 892 | +| cells the ore suppresses | **31** | +| cells that APPEAR when ore is added | **0** (the one-way property of #99, re-confirmed on the entity region) | +| cells merely re-coded | 5 | +| our model suppresses | **22**, of which genuinely suppressed **22** | +| | **precision 1.000, recall 0.710** | + +**The rule is exactly right where it fires and simply too narrow.** That is a +much more useful statement than "it explains 21 of 31", because it says which +direction is safe to move in. + +Attribution of the 31, from the per-control arms: **27 calcite, 4 geyser, 0 +tungsten/coal**. Of the 9 our predicate misses, **4 are geyser** cells that +`includeGeyser: false` deliberately excludes (the geyser rolls, so including it +costs precision - measured, still harmful), 5 are calcite, and **all 9 are +adjacent to another suppressed cell**. + +### The crossing stage explains 2 remainders for free + +The predicate fires on **20** placed cells; the placement loses **22**. The extra +two are neighbours left with a non-placing code after a rejected cell's edges +were zeroed (#108). No tuning - it falls out of the mechanism, and it is the +first thing to reduce the remainder count since the rule was characterised. + +### The cascade half of the open question is REFUTED + +`vulcanusOreRejection.ts` left it as "a cascade along cliff connections **or** a +wider box". #108 makes the cascade concrete: zeroing a cell's edges changes a +neighbour's code, hence its orientation, hence its collision box, so re-testing +to a fixpoint is exactly that cascade. `rejectionCascades` is the arm. + +| | matched | wrong | surplus | missing | +| --- | --- | --- | --- | --- | +| collapsed rule, one pass | 18654 | 693 | 1200 | 103 | +| collapsed rule, fixpoint | 18640 | 700 | 1196 | 110 | + +At the SHIPPING settings it is **bit-for-bit identical** - and the collapsed-rule +row is what makes that a result rather than an untriggered branch. A rejected +cell never turns a neighbour into a rejectable orientation. + +That leaves the wider-box half, which is the one #88 says must not be tuned into +fitting. + +### Half of `[1500,1500]`'s residual is not ore at all + +Running BOTH sides with the resources off isolates it: + +| | matched | wrong | surplus | missing | +| --- | --- | --- | --- | --- | +| resources ON, both sides | 842 | 16 | 19 | 3 | +| resources OFF, both sides | 876 | **13** | **10** | 3 | + +So 13 wrong orientations and 10 surplus cells survive with the ore entirely out +of the picture. Tuning the ore rule cannot reach them, and that non-ore half is +the larger target now. diff --git a/src/noise/cliffs/cliffPlacement.ts b/src/noise/cliffs/cliffPlacement.ts index e575e8bc..17884bb2 100644 --- a/src/noise/cliffs/cliffPlacement.ts +++ b/src/noise/cliffs/cliffPlacement.ts @@ -155,6 +155,16 @@ export interface CliffBands { * that 0 times where the model predicts 1,662. */ readonly rejectAtCrossingStage?: boolean; + /** + * With `rejectAtCrossingStage`, re-run the rejection pass until it finds + * nothing, so a cell whose ORIENTATION changed because a neighbour's edges + * were zeroed is re-tested with its new collision box. + * + * This is the "cascade along cliff connections" half of the open question in + * `vulcanusOreRejection.ts` - the other half being a wider box. Off by + * default; `test/cliffOreCascade.spec.ts` is what decides it. + */ + readonly rejectionCascades?: boolean; } /** Cells per chunk axis: a 32-tile chunk over the 4-tile placement grid. */ @@ -510,28 +520,40 @@ export function makeCliffPlacementFromFields( // Collect first, then clear: a cell's rejection is decided from // the code the repair left, not from a code a previous cell's // clearing has already eaten into. - const kill: number[] = []; - for (let cy = 0; cy < n; cy++) { - for (let cx = 0; cx < n; cx++) { - const code = cellCode( - v[cy * (n + 1) + cx], - v[cy * (n + 1) + cx + 1], - hEdges[cy * n + cx], - hEdges[(cy + 1) * n + cx], - ); - if (!isCliffPlaced(code)) continue; - const x = (baseX + cx) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X; - const y = (baseY + cy) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y; - if (rejected(code, x, y) || cellRejects?.(code, x, y) === true) kill.push(cx, cy); + // + // `rejectionCascades` re-runs that to a fixpoint. Zeroing a cell's + // edges changes its neighbours' codes, and a changed code is a + // changed ORIENTATION, so a neighbour can become rejectable when + // it was not before. Whether the game does that is a measurement, + // not a deduction - see `cliffOreCascade.spec.ts`. + for (let pass = 0; ; pass++) { + const kill: number[] = []; + for (let cy = 0; cy < n; cy++) { + for (let cx = 0; cx < n; cx++) { + const code = cellCode( + v[cy * (n + 1) + cx], + v[cy * (n + 1) + cx + 1], + hEdges[cy * n + cx], + hEdges[(cy + 1) * n + cx], + ); + if (!isCliffPlaced(code)) continue; + const x = (baseX + cx) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X; + const y = (baseY + cy) * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y; + if (rejected(code, x, y) || cellRejects?.(code, x, y) === true) + kill.push(cx, cy); + } } - } - for (let i = 0; i < kill.length; i += 2) { - const cx = kill[i]; - const cy = kill[i + 1]; - v[cy * (n + 1) + cx] = 0; - v[cy * (n + 1) + cx + 1] = 0; - hEdges[cy * n + cx] = 0; - hEdges[(cy + 1) * n + cx] = 0; + for (let i = 0; i < kill.length; i += 2) { + const cx = kill[i]; + const cy = kill[i + 1]; + v[cy * (n + 1) + cx] = 0; + v[cy * (n + 1) + cx + 1] = 0; + hEdges[cy * n + cx] = 0; + hEdges[(cy + 1) * n + cx] = 0; + } + // One pass is the shipping model; the cascade stops when a pass + // finds nothing, and `pass` is bounded by the cell count anyway. + if (bands.rejectionCascades !== true || kill.length === 0 || pass > 64) break; } } diff --git a/src/noise/cliffs/vulcanusOreRejection.ts b/src/noise/cliffs/vulcanusOreRejection.ts index ef7059be..51fda680 100644 --- a/src/noise/cliffs/vulcanusOreRejection.ts +++ b/src/noise/cliffs/vulcanusOreRejection.ts @@ -50,6 +50,28 @@ * overlapped cell - and whether that is a cascade along cliff connections or * a wider box is open. Widening the box until all 31 fall out is exactly how * #88 shipped a wrong model that scored perfectly. + * + * **Half of that is now settled, and the remainder count is down by two** + * (`test/cliffOreCascade.spec.ts`): + * + * - **The cascade half is REFUTED.** #108 established that a rejection zeroes + * the cell's edge registers, so a neighbour's code - hence its orientation, + * hence its collision box - changes. Re-testing to a fixpoint is exactly + * "a cascade along cliff connections", and `rejectionCascades` measures it: + * a bit-for-bit no-op at the shipping settings, and net harmful on the + * collapsed rule. Rejected cells do not turn neighbours rejectable. + * - **The crossing STAGE explains 2 of the remainders with no tuning at all.** + * The predicate fires on 20 placed cells; the placement loses 22, because + * zeroing a rejected cell's edges leaves two neighbours with codes that no + * longer place. + * - Scored against the lever rather than by totals, the rule is + * **precision 1.000, recall 0.710** (22 of 31): exactly right where it + * fires, simply too narrow. Of the 9 it misses, **4 are geyser** cells the + * `includeGeyser` default deliberately excludes, and 5 are calcite; all 9 + * are adjacent to another suppressed cell. + * + * That leaves the wider-box half open - and it is the half #88 says must not + * be tuned into fitting. */ import type { VulcanusResourceControls } from "../eval/ctx"; import type { VulcanusResources } from "../expressions/vulcanusResources"; diff --git a/test/cliffOreCascade.spec.ts b/test/cliffOreCascade.spec.ts new file mode 100644 index 00000000..a69dc53b --- /dev/null +++ b/test/cliffOreCascade.spec.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vite-plus/test"; + +import ore from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; +import sweep from "./fixtures/oracle-vulcanus-cliff-fine-sweep.seed123456.json"; +import { + CLIFF_CODE_TO_ORIENTATION, + 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"; + +/** + * **The ore rule's remaining error, measured against a LEVER rather than + * characterised** - and the refutation of the cascade half of + * `vulcanusOreRejection.ts`'s open question. + * + * That file states the position this spec moves: box overlap explains most of + * the suppressed cells, "the other 10 are run remainders - every one of the six + * connected components of the suppressed set contains a directly overlapped cell + * - and whether that is **a cascade along cliff connections or a wider box** is + * open." + * + * #108 supplied a mechanism that makes the cascade half concrete and testable: a + * rejection zeroes the cell's four edge registers, so its neighbours' codes - + * and therefore their ORIENTATIONS, and therefore their collision boxes - change. + * Re-running the rejection pass to a fixpoint is exactly "a cascade along cliff + * connections". `rejectionCascades` in `cliffPlacement.ts` is that arm. + * + * **It buys nothing.** At the shipping settings it is a bit-for-bit no-op, and on + * the collapsed rule it is net harmful. Half of the open question is now closed: + * the remainders are not a cascade of this predicate. + * + * The rest of the spec is the positive measurement the lever makes possible. + * `oracle-vulcanus-cliff-ore-direction` re-ran `[1500,1500]` with the resources + * switched off through `autoplace_controls`, so the ore's true effect is a known + * SET of cells rather than an inference, and the port's predicate can be scored + * for precision and recall against it instead of by how well the totals line up. + */ + +const INPUT = { seed0: ore.seed, 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 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])); +const gameCodeOf = (o: string): number | undefined => { + const id = nameToId.get(o); + return id === undefined ? undefined : codeForOrientation.get(id); +}; + +/** The entity region all four `autoplace_controls` arms were captured over. */ +const R = { x0: 1500, y0: 1500, x1: 1756, y1: 1756 }; +const inR = (x: number, y: number): boolean => x >= R.x0 && x < R.x1 && y >= R.y0 && y < R.y1; + +const gameSet = (label: string): Map => { + const c = ore.cases.find((k) => k.label === label); + if (c === undefined) throw new Error(`no case ${label}`); + const m = new Map(); + for (const e of c.cliffs) { + if (e.name !== "cliff-vulcanus" || !inR(e.x, e.y)) continue; + const code = gameCodeOf(e.orientation); + if (code !== undefined) m.set(`${String(e.x)},${String(e.y)}`, code); + } + return m; +}; + +/** Shipping settings, both rejections at the crossing stage (#108). */ +const portSet = (withOre: boolean, cascade = false): Map => + new Map( + makeCliffPlacementFromFields(fields, { + elevation0: VULCANUS_CLIFF_ELEVATION_0, + interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, + smoothing: VULCANUS_CLIFF_SMOOTHING, + tileCollides: (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), + cellRejects: withOre ? oreRejects : undefined, + rejectAtCrossingStage: true, + rejectionCascades: cascade, + }) + .placedCells(R.x0, R.y0, R.x1, R.y1) + .map((p) => [`${String(p.x)},${String(p.y)}`, p.code] as const), + ); + +const score = (ours: Map, game: Map): Record => { + let matched = 0; + let wrong = 0; + let surplus = 0; + let missing = 0; + for (const [k, c] of ours) { + const t = game.get(k); + if (t === undefined) surplus++; + else if (t === c) matched++; + else wrong++; + } + for (const k of game.keys()) if (!ours.has(k)) missing++; + return { matched, wrong, surplus, missing }; +}; + +describe("the ore rule's remainder, and whether it cascades", () => { + /** + * The lever gives the ore's effect as a SET, so the predicate gets a precision + * and a recall rather than a total to match. It is **exactly right where it + * fires and simply too narrow**: every cell it suppresses is one the game + * suppresses, and it reaches 22 of 31. + * + * Note `appeared` is 0. Removing a resource only ever ADDS cliffs, never + * removes one, which is the one-way property #99 established - re-confirmed + * here on the entity region rather than the blob. + */ + it("scores the ore predicate against the resources-off lever", () => { + const on = gameSet("entity region, resources ON"); + const off = gameSet("entity region, ALL resources OFF"); + const pOn = portSet(true); + const pOff = portSet(false); + + expect(on.size).toBe(861); + expect(off.size).toBe(892); + + const suppressed = [...off.keys()].filter((k) => !on.has(k)); + const appeared = [...on.keys()].filter((k) => !off.has(k)); + const recoded = [...off.keys()].filter((k) => on.has(k) && on.get(k) !== off.get(k)); + expect(suppressed.length).toBe(31); + expect(appeared.length).toBe(0); + expect(recoded.length).toBe(5); + + const oursSuppressed = [...pOff.keys()].filter((k) => !pOn.has(k)); + const truth = new Set(suppressed); + const hit = oursSuppressed.filter((k) => truth.has(k)).length; + expect(oursSuppressed.length).toBe(22); + expect(hit).toBe(22); // precision 1.000 - it never fires on a cell the game kept + expect(hit / suppressed.length).toBeCloseTo(0.7097, 3); + }, 120000); + + /** + * **The crossing stage explains two of the "run remainders" for free.** The + * predicate itself fires on 20 of the placed cells, but the placement loses + * 22 - because zeroing a rejected cell's edges can leave a NEIGHBOUR with a + * code that no longer places. That is not tuning; it falls out of #108's + * mechanism, and it is the first thing to have reduced the remainder count + * since the rule was characterised. + */ + it("the mechanism accounts for 2 remainders the bare predicate does not", () => { + const pOff = portSet(false); + let predicateFires = 0; + for (const [k, code] of pOff) { + const [xs, ys] = k.split(","); + if (oreRejects(code, Number(xs), Number(ys))) predicateFires++; + } + const pOn = portSet(true); + const lost = [...pOff.keys()].filter((k) => !pOn.has(k)).length; + + expect(predicateFires).toBe(20); + expect(lost).toBe(22); + expect(lost - predicateFires).toBe(2); + }, 120000); + + /** + * **The cascade half of the open question, refuted.** Re-testing to a fixpoint + * changes not one cell at the shipping settings, and on the collapsed rule it + * loses 14 matched cells and 7 orientations to gain 4 of the over-placement. + * + * So a rejected cell never turns a neighbour into a rejectable orientation. + * That leaves the "wider box" half of `vulcanusOreRejection.ts`'s question - + * which is the one that must NOT be tuned into fitting, per #88. + */ + it("re-testing to a fixpoint buys nothing at shipping and loses on the collapsed rule", () => { + const on = gameSet("entity region, resources ON"); + const plain = portSet(true); + const cascaded = portSet(true, true); + + // Bit-for-bit identical, not merely equal in total. + expect(cascaded.size).toBe(plain.size); + for (const [k, code] of plain) expect(cascaded.get(k)).toBe(code); + expect(score(plain, on)).toEqual(score(cascaded, on)); + + // And it is not a no-op everywhere - on the collapsed rule it is harmful, + // which is what makes the shipping no-op a real result rather than an + // untriggered branch. + const collapsed = (cascade: boolean): Record => { + let matched = 0; + let wrong = 0; + let surplus = 0; + let missing = 0; + for (const c of sweep.cases) { + const ours: Map = new Map( + makeCliffPlacementFromFields( + { cliffElevation: fields.cliffElevation, cliffiness: (): number => 1 }, + { + elevation0: c.level, + interval: 1000000, + smoothing: 0, + tileCollides: (x, y): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), + cellRejects: oreRejects, + rejectAtCrossingStage: true, + rejectionCascades: cascade, + }, + ) + .placedCells(sweep.region.x0, sweep.region.y0, sweep.region.x1, sweep.region.y1) + .map((p) => [`${String(p.x)},${String(p.y)}`, p.code] as const), + ); + const game = new Map(); + for (const e of c.cliffs) { + if (e.name !== "cliff-vulcanus" || !inR(e.x, e.y)) continue; + const code = gameCodeOf(e.orientation); + if (code !== undefined) game.set(`${String(e.x)},${String(e.y)}`, code); + } + for (const [k, code] of ours) { + const t = game.get(k); + if (t === undefined) surplus++; + else if (t === code) matched++; + else wrong++; + } + for (const k of game.keys()) if (!ours.has(k)) missing++; + } + return { matched, wrong, surplus, missing }; + }; + + expect(collapsed(false)).toEqual({ matched: 18654, wrong: 693, surplus: 1200, missing: 103 }); + expect(collapsed(true)).toEqual({ matched: 18640, wrong: 700, surplus: 1196, missing: 110 }); + }, 900000); + + /** + * **How much of `[1500,1500]`'s residual is ore at all.** Running BOTH sides + * with the resources off answers it: 13 wrong orientations and 10 surplus + * cells survive with the ore entirely out of the picture. So roughly half the + * region's remaining error has nothing to do with the ore rule, and tuning + * that rule cannot reach it. + */ + it("isolates the non-ore residual by running both sides with resources off", () => { + const off = gameSet("entity region, ALL resources OFF"); + const on = gameSet("entity region, resources ON"); + expect(score(portSet(false), off)).toEqual({ + matched: 876, + wrong: 13, + surplus: 10, + missing: 3, + }); + expect(score(portSet(true), on)).toEqual({ matched: 842, wrong: 16, surplus: 19, missing: 3 }); + }, 120000); +});