From cff41e5c1ffe823d61f19ecdda046c7ae35efa77 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 2 Aug 2026 14:13:15 -0700 Subject: [PATCH 1/5] feat(cliffs): port the ORE -> CLIFF rejection (#84 item 1) #99 characterised the rule and stopped short of porting it, flagging one open sub-question: whether driving it from our own resource model, rather than the game's entities, is accurate enough. It is - and it costs exactly one cell. Scored across all three oracle regions, driving `makeVulcanusOreRejection` off the same field stack the ore overlay paints from: | region | game | placed | fires | false rejections | surplus | | --- | --- | --- | --- | --- | --- | | [0,0] | 283 | 283 | 0 | 0 | 2 -> 2 | | [1500,1500] | 885 | 900 | 20 | 0 | 42 -> 22 | | [-1200,800] | 401 | 387 | 0 | 0 | 1 -> 1 | Precision at [1500,1500] 0.953 -> 0.975 with the 858 true positives untouched. **Recall is not touched anywhere**, which was the gate: this rule may only ever cost precision. Three variants were scored and the two that lose are kept in the spec rather than dismissed in a comment, because #88/#90 already paid for that lesson here: | variant | fires | correct of 31 | false rejections | | --- | --- | --- | --- | | base box, ores only (SHIPPED) | 20 | 20 | 0 | | base box + geyser | 21 | 20 | 1 | | per-orientation box | 23 | 21 | 2 | The geyser arm is strictly HARMFUL - one more false rejection and not one additional correct suppression - so it is implemented behind `includeGeyser`, defaulting off. The per-orientation rotbb box catches one more true cell and pays two kept cliffs for it; higher `correct` is exactly the trap. Not claimed: the mechanism is still open (the disassembly says cliffs are placed before any resource entity exists), and 11 of the 31 stay unexplained with the box deliberately not widened to cover them. The spec pins that 11. Lands as `CliffBands.cellRejects`, an opaque per-cell predicate beside `tileCollides` - so the shared cliff core stays planet-agnostic, and the model the specs score is the model the renderer ships. The predicate enumerates no entities: the overlapping tiles follow in closed form (2 tiles for an ore against the lava rejection's ~30), guarded by a wider brute-force scan. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c --- docs/noise/vulcanus-cliffs-NOTES.md | 89 +++++ .../2026-08-02-ore-cliff-rejection-design.md | 117 ++++++ src/noise/cliffs/cliffPlacement.ts | 25 ++ src/noise/cliffs/vulcanusOreRejection.ts | 180 +++++++++ src/noise/preview/renderVulcanusCliffs.ts | 8 + src/noise/preview/renderVulcanusResources.ts | 31 +- .../resources/vulcanusResourceCatalog.ts | 40 ++ test/cliffOreRejection.spec.ts | 376 ++++++++++++++++++ 8 files changed, 861 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-02-ore-cliff-rejection-design.md create mode 100644 src/noise/cliffs/vulcanusOreRejection.ts create mode 100644 test/cliffOreRejection.spec.ts diff --git a/docs/noise/vulcanus-cliffs-NOTES.md b/docs/noise/vulcanus-cliffs-NOTES.md index 58a5ce80..120913b0 100644 --- a/docs/noise/vulcanus-cliffs-NOTES.md +++ b/docs/noise/vulcanus-cliffs-NOTES.md @@ -45,6 +45,18 @@ > the three regions, measured as the boolean `crossesCliff` reads rather than as > a value. That closes the clamp-vacuity worry properly. > +> ## UPDATE 3, 2026-08-02: the ore rule is PORTED and SHIPS +> +> The last section, **`## The rule is PORTED, and driving it from our own ore +> model costs one cell`**, supersedes the previous one's closing warning not to +> port this against our own resource positions without measuring that arm. The +> arm is measured: **zero false rejections across all three oracle regions**, and +> `[1500,1500]`'s surplus falls 42 -> 22 (precision 0.953 -> 0.975) with recall +> untouched. It ships as `CliffBands.cellRejects`, ores only, base collision box. +> The geyser arm and the per-orientation box were both scored and both LOSE - see +> the table there before re-proposing either. 11 of the 31 remain unexplained and +> the box is deliberately not widened to cover them. +> > ## UPDATE, 2026-08-02: the FIELD is exonerated; the residual is two defects > > The last section, **`## The residual is TWO defects, and the field is not @@ -1534,3 +1546,80 @@ without checking that arm separately.** The 31/0 score above uses the GAME's resource entities as the input, which isolates the rule from the accuracy of the resource port; driving it from `renderVulcanusResources` is a second question and has not been measured. + +## The rule is PORTED, and driving it from our own ore model costs one cell (#84 item 1, 2026-08-02) + +The section above closes with "driving it from `renderVulcanusResources` is a +second question and has not been measured." It is measured now, and the answer +is that it is safe: `test/cliffOreRejection.spec.ts` scores the shipped +predicate - `makeVulcanusOreRejection`, driven off `buildResources`, the same +field stack the ore overlay paints from - across all three oracle regions. + +| region | game | port placed | fires | **false rejections** | surplus | +| --- | --- | --- | --- | --- | --- | +| `[0,0]` | 283 | 283 | 0 | 0 | 2 -> 2 | +| `[1500,1500]` | 885 | 900 | 20 | **0** | 42 -> **22** | +| `[-1200,800]` | 401 | 387 | 0 | 0 | 1 -> 1 | + +Precision at `[1500,1500]` goes 0.953 -> **0.975** with the 858 true positives +untouched. **Recall is not touched anywhere**, which was the gate: this rule may +only ever cost precision, and a cell removed that the game kept would be the one +outcome worth refusing. + +Driving it from our own ore model rather than the game's entities costs exactly +**one** cell - the fixture-driven geometry explains 21 of the 31, the port-driven +one 20. That is the whole price of the substitution the section above flagged. + +### Three variants were scored, and the two that lose are in the spec + +Not dismissed in a comment, because #88/#90 already paid for that lesson here - +the best-scoring collision model was the wrong one. + +| variant | fires | correct of 31 | false rejections | +| --- | --- | --- | --- | +| **base box, ores only (SHIPPED)** | 20 | 20 | **0** | +| base box + geyser | 21 | 20 | 1 | +| per-orientation box | 23 | 21 | 2 | +| per-orientation + geyser | 24 | 21 | 3 | + +- **The geyser arm is strictly harmful**, not merely risky: one more false + rejection and *not one* additional correct suppression. Its placements are + salt-dependent (46-63 over eight salts against the game's 56) and its box is + 14x the ores', so a geyser in the wrong place sweeps a wide area. It is + implemented behind `includeGeyser`, defaulting off, so the arm stays scored + rather than deleted. +- **The per-orientation rotbb box catches one MORE true cell and pays two kept + cliffs for it.** Higher `correct` is exactly the trap: recall is the half that + must not be traded. Note this means the ore rule and the lava rejection use + *different* cliff rectangles - the base `collision_box` and the per-orientation + one respectively - which is only defensible because the ore mechanism is open + and the base box is the shape it was measured with. If the mechanism is ever + found, revisit this first. + +### What is NOT claimed + +**11 of the 31 are still unexplained** and the box is deliberately not widened +until they fall out: 10 are #99's run remainders and 1 is the cell our ore model +misses. `test/cliffOreRejection.spec.ts` pins that 11 so the gap stays tracked. + +The **mechanism is still open**. This ships a characterised empirical rule - +one-way, additive, local, box-shaped - and the disassembly still says cliffs are +computed and placed before any resource entity exists, so whatever the engine is +really doing, it is not the collision test this models. + +### Where it lives + +`CliffBands.cellRejects`, a second optional per-cell predicate beside +`tileCollides` in `cliffPlacement.ts`, applied at the same site. It is +deliberately opaque - the shared cliff core stays planet-agnostic, and a +planet-specific, mechanism-open rule does not leak into it. It hangs there rather +than filtering `placedCells`' output so that **the model the specs score is the +model the renderer ships**; every spec drives `makeCliffPlacementFromFields` +directly, so a filter further out would score a different thing than it renders. + +Two cheapnesses worth knowing: the predicate never enumerates entities (it solves +the two rectangles for the tiles whose centres can overlap - exactly 2 tiles for +an ore, 4x3 for a geyser, against the lava rejection's ~30), and it reuses the +composite's `VulcanusStack.resources` rather than building a second DAG. The +derived window is guarded by a brute-force scan a tile wider on every side, not +trusted. diff --git a/docs/superpowers/specs/2026-08-02-ore-cliff-rejection-design.md b/docs/superpowers/specs/2026-08-02-ore-cliff-rejection-design.md new file mode 100644 index 00000000..a33ebd09 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-ore-cliff-rejection-design.md @@ -0,0 +1,117 @@ +# Porting the ORE -> CLIFF rejection (issue #84, item 1) + +**Date:** 2026-08-02 +**Status:** implemented, measured, shipped on `feat/cliff-ore-rejection` + +> Point-in-time design record, per `docs/superpowers/specs/` convention. Not a +> living document - the current state of the rule lives in +> `docs/noise/vulcanus-cliffs-NOTES.md` and `src/noise/cliffs/vulcanusOreRejection.ts`. + +## Problem + +PR #99 settled that Vulcanus resources suppress cliffs (`ORE -> CLIFF`, not the +reverse) and characterised the rule as one-way, additive, local, and shaped like +a box overlap against the resource entity's rectangle. It scored 21 of 31 +suppressed cells with zero false alarms in 885. + +It deliberately stopped short of porting it, flagging one open sub-question: + +> whether driving it from `renderVulcanusResources` (rather than the game's +> entities, which is what the 31/0 score uses) is accurate enough + +That is this work. + +## Decisions, and what settled each + +Four forks. Each was settled by a measurement taken during implementation, not +by preference - the numbers are in `test/cliffOreRejection.spec.ts`. + +| decision | chosen | what settled it | +| --- | --- | --- | +| geyser included? | **no**, behind `includeGeyser` | strictly harmful: +1 false rejection, +0 correct | +| cliff rectangle | **prototype base box** | per-orientation catches +1 true cell and costs 2 kept cliffs | +| the unexplained 10 | **not tuned away** | pinned as a gap; #88/#90's lesson | +| where the predicate hangs | **`CliffBands.cellRejects`** | keeps the scored model and the shipped model identical | + +### Why recall is the gate + +The rule can only ever remove cells. Every cell it fires on at `[1500,1500]` is +surplus, so it is pure precision gain - unless it removes a cliff the game kept, +which is a false rejection and costs recall. Recall is the expensive half of this +port (1.000/0.973/0.965), so "zero false rejections" is a hard gate, not a +report. Both rejected variants fail exactly that gate. + +## Architecture + +``` +CliffBands.cellRejects?: (code, x, y) => boolean // cliffPlacement.ts, beside tileCollides + ^ supplied by +makeVulcanusOreRejection(resources, controls, opts) // cliffs/vulcanusOreRejection.ts + ^ wired by +renderVulcanusCliffs // reads VulcanusStack.resources +``` + +`cellRejects` is applied in both paths of `placedCells` (chunked and unchunked), +after the bounds test and after `tileCollides`. It is deliberately an opaque +predicate: the cliff core is planet-agnostic engine behaviour, and this rule is +neither planet-agnostic nor engine-confirmed. + +**It hangs there rather than filtering `placedCells`' output** because every spec +drives `makeCliffPlacementFromFields` directly. A filter applied further out +would mean the specs score an unfiltered model while the renderer ships a +filtered one - the same class of silent divergence as the +`worker-configuration.d.ts` drift. + +Like `tileCollides` it is a pure per-cell post-filter with no effect on +neighbours, so worker tiling stays byte-identical for free. + +### The predicate + +- **Cliff box**: `+/-0.98828125 x +/-0.48828125`, the prototype `collision_box` + the fixture carries - *not* the per-orientation rotbb the lava rejection uses. +- **Ore footprint**: `makeVulcanusOreFootprint`, sharing + `RESOURCE_PROBABILITY_THRESHOLD` with the ore overlay so the two cannot drift + onto different footprints. A control at `size = 0` occupies nothing, which is + the same lever the game was driven with. +- **No entity enumeration.** The tiles whose centres can overlap follow in closed + form from the two rectangles: exactly 2 tiles for an ore, 4x3 for a geyser, + against the lava rejection's ~30. The derivation is guarded by a brute-force + scan a tile wider on every side, not trusted. +- Reuses the composite's `VulcanusStack.resources`; `memoXY` is single-entry, so + a private DAG would share nothing and pay for the whole tree again. + +## Results + +| region | game | placed | fires | false rejections | surplus | +| --- | --- | --- | --- | --- | --- | +| `[0,0]` | 283 | 283 | 0 | 0 | 2 -> 2 | +| `[1500,1500]` | 885 | 900 | 20 | **0** | 42 -> **22** | +| `[-1200,800]` | 401 | 387 | 0 | 0 | 1 -> 1 | + +Precision at `[1500,1500]`: 0.953 -> **0.975**, true positives untouched. Driving +from the port's own ore model instead of the game's entities costs exactly one +cell (20 against 21). + +## What is deliberately not claimed + +- **The mechanism is open.** The disassembly still says cliffs are computed and + placed before any resource entity exists, so this is a characterised empirical + rule, not a port of a known engine path. Both the module comment and the notes + say so. +- **11 of 31 are unexplained** (10 run remainders + 1 our ore model misses) and + the box is not widened until they fall out. +- **The two cliff rectangles now disagree** - base box for ore, per-orientation + for lava. Defensible only while the ore mechanism is open; revisit first if it + is ever found. + +## Testing + +`test/cliffOreRejection.spec.ts`, 8 tests. Scores the shipped predicate across +all three oracle regions; scores both rejected variants so the choice is a +record rather than an assumption; pins the remainder at 11; guards the derived +tile window; asserts the disable path fires zero times; and cross-checks that the +rejection's footprint equals the ore overlay's painted pixels. + +Two non-vacuity guards earned their keep: the footprint cross-check was +initially vacuous on a 64x64 window containing no ore, and `painted > 0` caught +it. diff --git a/src/noise/cliffs/cliffPlacement.ts b/src/noise/cliffs/cliffPlacement.ts index 5087c906..08bba0f3 100644 --- a/src/noise/cliffs/cliffPlacement.ts +++ b/src/noise/cliffs/cliffPlacement.ts @@ -114,6 +114,28 @@ export interface CliffBands { * placed cell, and only for cells that are actually placed. */ readonly tileCollides?: (x: number, y: number) => boolean; + /** + * An additional per-cell rejection, called with the cell's crossing `code` and + * its centre, for cells that survive the bounds test and `tileCollides`. + * Return `true` to drop the cell. + * + * **Deliberately opaque.** This module is planet-agnostic - the corner + * lattice, `crossesCliff` and the orientation table are engine behaviour - and + * the one rule that currently uses this hook is not engine behaviour at all + * but a characterised empirical one (Vulcanus's ORE -> CLIFF suppression, see + * `vulcanusOreRejection.ts`). Keeping it a bare predicate is what stops a + * planet-specific and mechanism-open rule from leaking into the shared core. + * + * It runs at the same site as `tileCollides` rather than as a filter over + * `placedCells`' return value so that the model the specs score is the model + * the renderer ships; every spec here drives `makeCliffPlacementFromFields` + * directly, so a filter applied further out would score a different thing than + * it renders. + * + * Like `tileCollides` this is a pure post-filter on the emit loop - it cannot + * affect a neighbouring cell, so it leaves worker tiling byte-identical. + */ + readonly cellRejects?: (code: number, x: number, y: number) => boolean; } /** Cells per chunk axis: a 32-tile chunk over the 4-tile placement grid. */ @@ -313,6 +335,7 @@ export function makeCliffPlacementFromFields( const { elevation0: e0, interval } = bands; const smoothing = bands.smoothing ?? 0; const tileCollides = bands.tileCollides; + const cellRejects = bands.cellRejects; /** * `tryToAddCliff`'s rejection, as a predicate on an already-placed cell: scan @@ -480,6 +503,7 @@ export function makeCliffPlacementFromFields( // overhangs the query box. if (x < x0 || x >= x1 || y < y0 || y >= y1) continue; if (rejected(code, x, y)) continue; + if (cellRejects?.(code, x, y) === true) continue; result.push({ x, y, code }); } } @@ -508,6 +532,7 @@ export function makeCliffPlacementFromFields( const y = cy * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y; if (x < x0 || x >= x1 || y < y0 || y >= y1) continue; if (rejected(code, x, y)) continue; + if (cellRejects?.(code, x, y) === true) continue; result.push({ x, y, code }); } } diff --git a/src/noise/cliffs/vulcanusOreRejection.ts b/src/noise/cliffs/vulcanusOreRejection.ts new file mode 100644 index 00000000..ef7059be --- /dev/null +++ b/src/noise/cliffs/vulcanusOreRejection.ts @@ -0,0 +1,180 @@ +/** + * The ORE -> CLIFF rejection: a resource entity's collision rectangle overlapping + * a cliff cell's suppresses that cliff. + * + * ## What is established, and what is not + * + * **Established, by a lever rather than an argument** (#99, + * `test/cliffOreDirection.spec.ts`). `autoplace_controls` is settable on the + * surface exactly like `cliff_settings`, so the game can be re-run with the + * resources switched off (`size = 0`) over the same regions. It gives both arms: + * turning the ore off fills all ten cells of the blob the game otherwise leaves + * empty, and forcing 335 cliffs through the tungsten field against the default's + * 283 moves the ore not one tile. The rule is therefore + * + * - **one-way** - removing a resource only ever ADDS cliffs, never removes one, + * - **additive** - 27 calcite + 4 geyser = exactly the 31 of all-off, disjoint, + * - **local**, and shaped like a BOX OVERLAP against the resource ENTITY's + * rectangle rather than "a resource tile lies in the 4x4 cell". That + * distinction is only visible because `sulfuric-acid-geyser`'s collision + * half-extent is 1.398 against the ores' 0.098: a point-at-tile-centre test + * explains the calcite cells and cannot explain the geyser ones. + * + * **NOT established: the mechanism.** This is a characterised empirical rule, + * not a port of a known engine path, and the difference matters enough to state + * at the top of the file. The obvious candidate is refuted: + * `EntityMapGenerationTask::computeInternal` (`0x101622860`) calls + * `generateCliffs` at `+44` and `generateEntities` at `+148`, and `apply` + * (`0x101623b48`) calls `applyCliffs` at `+124` and `applyEntities` at `+164`, + * so cliffs are both computed and placed BEFORE any resource entity exists. No + * collision test can see an entity that is not there yet. The masks are disjoint + * too (resources carry only the `resource` layer, which the cliff mask does not + * hold). + * + * ## Two things here are deliberately NOT the shape you might expect + * + * 1. **The cliff rectangle is the prototype's BASE `collision_box`, not the + * per-orientation rotbb box** that the lava rejection uses + * (`CLIFF_ORIENTATION_COLLISION_BOX`). Those are materially different shapes + * - the base box is `+/-0.988 x +/-0.488`, while orientation 4's rotbb is + * `[-3.5,-3,4.5,3]`. The base box is the one the rule was measured with, and + * since the mechanism is open there is nothing that says the ore rule should + * reuse the collision path's shape. `test/cliffOreRejection.spec.ts` scores + * BOTH so the choice is a recorded measurement rather than an assumption - + * which is the lesson #88/#90 already paid for, where the best-scoring + * collision model was the wrong one because it absorbed an unrelated defect. + * 2. **It does not explain all 31 cells, and it is not tuned until it does.** + * Box overlap accounts for 21 of the 31 with zero false alarms in the 885 + * cliffs the game kept. 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. Widening the box until all 31 fall out is exactly how + * #88 shipped a wrong model that scored perfectly. + */ +import type { VulcanusResourceControls } from "../eval/ctx"; +import type { VulcanusResources } from "../expressions/vulcanusResources"; +import { makeVulcanusOreFootprint } from "../resources/vulcanusResourceCatalog"; +import type { CliffCollisionBox } from "./cliffCatalog"; +import { CLIFF_ORIENTATION_COLLISION_BOX, cliffOrientationForCode } from "./cliffCatalog"; + +/** + * `cliff-vulcanus`'s prototype `collision_box`, read off a running game + * (`LuaEntityPrototype.collision_box`) and carried in + * `oracle-vulcanus-cliff-ore-direction.seed123456.json` as + * `protos["cliff-vulcanus"].box`, so the fixture holds the number rather than + * this file asserting it. Quantised to 1/256 because `MapPosition` is 8-bit + * fixed point: `0.98828125 = 253/256`, `0.48828125 = 125/256`. + */ +export const VULCANUS_CLIFF_BASE_COLLISION_BOX: CliffCollisionBox = [ + -0.98828125, -0.48828125, 0.98828125, 0.48828125, +]; + +/** + * The three solid ores' collision half-extent, `0.09765625 = 25/256`, identical + * across `tungsten-ore`, `calcite` and `coal` (same fixture). + */ +export const VULCANUS_ORE_COLLISION_HALF = 0.09765625; + +/** + * `sulfuric-acid-geyser`'s collision half-extent, `1.3984375 = 358/256` - the + * 2.8 x 2.8 box from `space-age/prototypes/entity/resources.lua:182`. More than + * fourteen times the ores' in each axis, which is what makes the geometry + * measurable at all. + */ +export const VULCANUS_GEYSER_COLLISION_HALF = 1.3984375; + +/** Which cliff rectangle the rejection tests with. */ +export type CliffRejectionBox = "base" | "orientation"; + +export interface VulcanusOreRejectionOptions { + /** + * Include the sulfuric-acid geyser as a suppressing entity. **Defaults to + * false**, and that default is a measurement, not caution for its own sake. + * + * The three solid ores THRESHOLD off region fields the oracle validates to + * ~1e-3, and the region saturates, so their footprint boundary is sharp and + * essentially deterministic. The geyser ROLLS: its placements are + * salt-dependent, and re-running one region over eight salts gives 46-63 + * entities against the game's 56 (see `makeVulcanusGeyserPlacement`). A geyser + * our model puts in the wrong place, with a box 14x the ores', removes a cliff + * the game KEPT - a false rejection, which costs recall. This rule is + * otherwise pure precision, so recall loss is the one outcome worth gating + * against. `test/cliffOreRejection.spec.ts` measures the arm both ways. + */ + readonly includeGeyser?: boolean; + /** + * Which cliff rectangle to test with - see the module comment. `"base"` is the + * shape the rule was measured with and the shipping default. + */ + readonly box?: CliffRejectionBox; + /** + * The geyser placement predicate, when `includeGeyser` is set. Injected rather + * than built here so the caller can hand over the composite's one + * `VulcanusStack` - see `geyserPlacementFrom`. + */ + readonly geyserAt?: (x: number, y: number) => boolean; +} + +interface Suppressor { + readonly half: number; + readonly occupies: (x: number, y: number) => boolean; +} + +/** + * The cliff rectangle for a cell, relative to its centre. The `"orientation"` + * variant falls back to the base box for a code that places nothing, which + * cannot reach the predicate anyway. + */ +function cliffBoxFor(box: CliffRejectionBox, code: number): CliffCollisionBox { + if (box === "base") return VULCANUS_CLIFF_BASE_COLLISION_BOX; + const id = cliffOrientationForCode(code); + return id === undefined ? VULCANUS_CLIFF_BASE_COLLISION_BOX : CLIFF_ORIENTATION_COLLISION_BOX[id]; +} + +/** + * Build the `CliffBands.cellRejects` predicate for Vulcanus: true when the + * cell's cliff rectangle overlaps a resource entity's. + * + * ## Why this is cheap + * + * It looks like it needs the set of resource entities near the cell, but it does + * not. A resource entity sits at a tile centre, so the tiles whose centre can + * possibly overlap follow in closed form from the two rectangles, and the + * predicate just asks the footprint about each of them. Cell centres sit at + * integer `x` and half-integer `y` (`cx*4+2`, `cy*4+2.5`), so for the base box + * against an ore that window is exactly **two tiles**; the geyser's larger box + * widens it to 4x3. Both are well under the lava rejection's ~30 tile lookups + * per cell, and no entity enumeration or spatial index is needed. + * + * The window is derived rather than hardcoded, and + * `test/cliffOreRejection.spec.ts` asserts that widening it by a tile on every + * side changes no cell - so the derivation is guarded, not trusted. + */ +export function makeVulcanusOreRejection( + resources: VulcanusResources, + controls: VulcanusResourceControls, + opts: VulcanusOreRejectionOptions = {}, +): (code: number, x: number, y: number) => boolean { + const boxKind = opts.box ?? "base"; + const suppressors: Suppressor[] = [ + { half: VULCANUS_ORE_COLLISION_HALF, occupies: makeVulcanusOreFootprint(resources, controls) }, + ]; + if (opts.includeGeyser === true && opts.geyserAt !== undefined) + suppressors.push({ half: VULCANUS_GEYSER_COLLISION_HALF, occupies: opts.geyserAt }); + + return (code, x, y) => { + const [l, t, r, b] = cliffBoxFor(boxKind, code); + for (const s of suppressors) { + // An entity centred at (tx + 0.5, ty + 0.5) overlaps when its box and the + // cliff's do, strictly - the same `<` the measurement used. Solving for tx + // gives the inclusive tile window below. + const txMin = Math.floor(x + l - s.half - 0.5) + 1; + const txMax = Math.ceil(x + r + s.half - 0.5) - 1; + const tyMin = Math.floor(y + t - s.half - 0.5) + 1; + const tyMax = Math.ceil(y + b + s.half - 0.5) - 1; + for (let tx = txMin; tx <= txMax; tx++) + for (let ty = tyMin; ty <= tyMax; ty++) if (s.occupies(tx, ty)) return true; + } + return false; + }; +} diff --git a/src/noise/preview/renderVulcanusCliffs.ts b/src/noise/preview/renderVulcanusCliffs.ts index 541a4cc4..dd52b9c7 100644 --- a/src/noise/preview/renderVulcanusCliffs.ts +++ b/src/noise/preview/renderVulcanusCliffs.ts @@ -31,7 +31,9 @@ import { VULCANUS_CLIFF_SMOOTHING, makeVulcanusCliffFields, } from "../cliffs/vulcanusCliffFields"; +import { makeVulcanusOreRejection } from "../cliffs/vulcanusOreRejection"; import { paintCliffCells } from "./renderCliffs"; +import { buildResources } from "./renderVulcanusResources"; import { type VulcanusStack, makeVulcanusTileResolver, @@ -96,11 +98,17 @@ export function renderVulcanusCliffs(base: ImageData, opts: RenderVulcanusCliffs // the window and break tiled equality. const tileAt = shared === undefined ? makeVulcanusTileResolver(ctx) : makeVulcanusTileResolverFrom(shared); + // The ORE -> CLIFF suppression (#84 item 1). Same sourcing rule as the tile + // resolver above: the composite's own resource stack when there is one, so the + // two overlays agree on where the ore is by construction rather than by + // coincidence, and a private DAG only when running standalone. + const resources = shared?.resources ?? buildResources(ctx); const placement = makeCliffPlacementFromFields(makeVulcanusCliffFields(ctx, shared), { elevation0: VULCANUS_CLIFF_ELEVATION_0, interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, smoothing: VULCANUS_CLIFF_SMOOTHING, tileCollides: (x, y) => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), + cellRejects: makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls), }); const box = opts.cellQueryBox ?? { diff --git a/src/noise/preview/renderVulcanusResources.ts b/src/noise/preview/renderVulcanusResources.ts index 6a3377e8..d10f8538 100644 --- a/src/noise/preview/renderVulcanusResources.ts +++ b/src/noise/preview/renderVulcanusResources.ts @@ -50,6 +50,7 @@ import { } from "../placement/placementRoll"; import type { PlacementCollisionBox } from "../placement/placementRoll"; import { + RESOURCE_PROBABILITY_THRESHOLD, VULCANUS_RESOURCE_CATALOG, sulfuricAcidGeyserProbability, } from "../resources/vulcanusResourceCatalog"; @@ -60,8 +61,12 @@ import { } from "../tiles/vulcanusCatalog"; import { paintMark } from "./renderCliffs"; -/** The overlay's placement threshold: probability >= 0.5 (see the module comment). */ -const PROBABILITY_THRESHOLD = 0.5; +/** + * The overlay's placement threshold: probability >= 0.5 (see the module + * comment). Defined in `vulcanusResourceCatalog.ts` because the cliff overlay's + * ore rejection asks the same question - see `RESOURCE_PROBABILITY_THRESHOLD`. + */ +const PROBABILITY_THRESHOLD = RESOURCE_PROBABILITY_THRESHOLD; /** * The two Vulcanus tiles no geyser may sit on. @@ -110,8 +115,16 @@ const GEYSER_FORBIDDEN_TILES = new Set(["lava", "lava-hot"]); */ const GEYSER_COLLISION_BOX: PlacementCollisionBox = { w: 2.8, h: 2.8 }; -/** Build the Vulcanus resource field stack `renderVulcanusResources` sweeps. */ -function buildResources(ctx: EvalCtx): VulcanusResources { +/** + * Build the Vulcanus resource field stack `renderVulcanusResources` sweeps. + * + * Exported because the cliff overlay needs the same stack for its ore rejection + * when it runs standalone (with a shared `VulcanusStack` it takes + * `stack.resources` instead). Assembling the sub-DAG by hand in a second place + * is precisely the duplication that lets two callers drift onto different + * fields. + */ +export function buildResources(ctx: EvalCtx): VulcanusResources { const helpers = makeVulcanusHelpers(ctx); const spawn = makeVulcanusSpawn(ctx, helpers); const cracks = makeVulcanusCracks(ctx, helpers); @@ -119,7 +132,15 @@ function buildResources(ctx: EvalCtx): VulcanusResources { return makeVulcanusResources(ctx, helpers, spawn, biomes, cracks); } -function geyserPlacementFrom( +/** + * The geyser placement predicate over an ALREADY-BUILT resource stack. + * + * Exported (unlike the ctx-only `makeVulcanusGeyserPlacement` below) so the + * cliff overlay's ore rejection can reuse the composite's one `VulcanusStack` + * instead of building a second field DAG: `memoXY` is single-entry, so a private + * copy would share nothing and pay for the whole tree again. + */ +export function geyserPlacementFrom( ctx: EvalCtx, resources: VulcanusResources, stack?: VulcanusStack, diff --git a/src/noise/resources/vulcanusResourceCatalog.ts b/src/noise/resources/vulcanusResourceCatalog.ts index bb248752..ac18e8a1 100644 --- a/src/noise/resources/vulcanusResourceCatalog.ts +++ b/src/noise/resources/vulcanusResourceCatalog.ts @@ -32,6 +32,46 @@ import type { VulcanusResourceControls, VulcanusResourceLevers } from "../eval/ctx"; import type { VulcanusResources } from "../expressions/vulcanusResources"; +/** + * The threshold a `"threshold"` entry's probability must clear for the game to + * have placed an ore entity on that tile: `probability >= 0.5`. + * + * **This lives here rather than in the renderer because it now has two + * consumers.** `renderVulcanusResources` paints with it, and + * `makeVulcanusOreRejection` (`../cliffs/vulcanusOreRejection.ts`) asks the same + * question to decide whether an ore entity suppresses a cliff. Two copies of the + * number could drift apart and the cliff overlay would then reject against a + * footprint the ore overlay does not draw - a disagreement that would be + * invisible in both renders. `test/cliffOreRejection.spec.ts` pins the two + * footprints equal on top of sharing this constant. + */ +export const RESOURCE_PROBABILITY_THRESHOLD = 0.5; + +/** + * Does the game hold a solid-ore entity on the tile whose centre is + * `(x + 0.5, y + 0.5)`? + * + * The three solid ores THRESHOLD (see `VulcanusResourcePlacement`), so their + * footprint is exactly `1000 * region >= RESOURCE_PROBABILITY_THRESHOLD` over + * the entries whose `size` lever is positive. A disabled ore occupies nothing, + * which is not a special case bolted on: it is the same `size = 0` lever the + * game itself was driven with to establish that ore suppresses cliffs (#99). + * + * The geyser is deliberately absent - it ROLLS rather than thresholds, so it has + * no footprint expressible this way. Callers that want it pass their own + * predicate. + */ +export function makeVulcanusOreFootprint( + resources: VulcanusResources, + controls: VulcanusResourceControls, +): (x: number, y: number) => boolean { + const active = VULCANUS_RESOURCE_CATALOG.filter( + (p) => p.placement === "threshold" && p.levers(controls).size > 0, + ).map((p) => p.region(resources)); + if (active.length === 0) return () => false; + return (x, y) => active.some((region) => 1000 * region(x, y) >= RESOURCE_PROBABILITY_THRESHOLD); +} + /** * How this entry decides where it is drawn. * diff --git a/test/cliffOreRejection.spec.ts b/test/cliffOreRejection.spec.ts new file mode 100644 index 00000000..09cf6f0d --- /dev/null +++ b/test/cliffOreRejection.spec.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from "vite-plus/test"; + +import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; +import direction from "./fixtures/oracle-vulcanus-cliff-ore-direction.seed123456.json"; +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 { + VULCANUS_CLIFF_BASE_COLLISION_BOX, + VULCANUS_ORE_COLLISION_HALF, + makeVulcanusOreRejection, +} from "../src/noise/cliffs/vulcanusOreRejection"; +import { VULCANUS_CLIFF_BLOCKING_TILES } from "../src/noise/preview/renderVulcanusCliffs"; +import { + buildResources, + geyserPlacementFrom, + renderVulcanusResources, +} from "../src/noise/preview/renderVulcanusResources"; +import { makeVulcanusOreFootprint } from "../src/noise/resources/vulcanusResourceCatalog"; +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)}`; + +interface Ent { + x: number; + y: number; + name: string; +} +interface Region { + x0: number; + y0: number; + x1: number; + y1: number; +} +interface Case { + region: Region; + cliffs: Ent[]; +} +interface Arm { + label: string; + region: Region; + cliffs: Ent[]; + resources: Ent[]; +} + +const INPUT = { seed0: 123456, startingPositions: [{ x: 0, y: 0 }] }; +const ctx = withCtxDefaults(INPUT); +const resources = buildResources(ctx); +const geyserAt = geyserPlacementFrom(ctx, resources); +const cliffFields = makeVulcanusCliffFields(ctx); +const tileAt = makeVulcanusTileResolver(INPUT); + +const gameCells = (cliffs: Ent[]): Set => + new Set(cliffs.filter((e) => e.name === "cliff-vulcanus").map((e) => key(e.x, e.y))); + +/** The port's placed cells with the lava rejection but WITHOUT the ore rule. */ +const placedWithoutOreRule = (r: Region): { x: number; y: number; code: number }[] => + makeCliffPlacementFromFields(cliffFields, { + elevation0: VULCANUS_CLIFF_ELEVATION_0, + interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, + smoothing: VULCANUS_CLIFF_SMOOTHING, + tileCollides: (x, y) => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name), + }).placedCells(r.x0, r.y0, r.x1, r.y1); + +const regionCase = (x0: number): Case => { + const c = (entities.cases as unknown as Case[]).find((k) => k.region.x0 === x0); + if (c === undefined) throw new Error(`no region ${String(x0)}`); + return c; +}; + +/** + * **The ORE -> CLIFF rejection as the renderer actually runs it.** + * + * `#99` settled the direction and characterised the rule, then handed over one + * explicitly open sub-question: it scored the geometry against the GAME's own + * resource entities, read out of a fixture, and noted that whether driving the + * rejection from the port's own resource model is accurate enough was + * "deliberately not attempted here". + * + * That is what this file measures. Every score below drives + * `makeVulcanusOreRejection` off `buildResources` - the same field stack + * `renderVulcanusResources` paints from - so it is the shipped predicate being + * scored, not an idealised one. + */ +describe("the ported ore rejection, driven by the port's own resource model", () => { + /** + * **The headline, and the gate.** The rule may only ever cost precision. A + * cell it removes that the game KEPT is a false rejection and costs recall, + * which is currently 1.000/0.973/0.965 and is the expensive half of this port. + * + * Across all three oracle regions the shipped variant raises **zero** false + * rejections, and at `[1500,1500]` it removes 20 of the 42 surplus cells - a + * 48% cut in over-placement for no recall at all. + */ + it("removes 20 surplus cells at [1500,1500] and never a cliff the game kept", () => { + const reject = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); + const scores = (entities.cases as unknown as Case[]).map((c) => { + const game = gameCells(c.cliffs); + const placed = placedWithoutOreRule(c.region); + const fired = placed.filter((p) => reject(p.code, p.x, p.y)); + return { + at: key(c.region.x0, c.region.y0), + game: game.size, + placed: placed.length, + fired: fired.length, + falseRejections: fired.filter((p) => game.has(key(p.x, p.y))).length, + surplusBefore: placed.filter((p) => !game.has(key(p.x, p.y))).length, + }; + }); + + expect(scores).toEqual([ + { at: "0,0", game: 283, placed: 283, fired: 0, falseRejections: 0, surplusBefore: 2 }, + { + at: "1500,1500", + game: 885, + placed: 900, + fired: 20, + falseRejections: 0, + surplusBefore: 42, + }, + { + at: "-1200,800", + game: 401, + placed: 387, + fired: 0, + falseRejections: 0, + surplusBefore: 1, + }, + ]); + + // Every cell it fires on is surplus, so surplus falls by exactly the fired + // count: 42 -> 22. Precision at [1500,1500] goes 858/900 = 0.953 to + // 858/880 = 0.975, with the 858 true positives untouched. + const heavy = scores[1]; + expect(heavy.surplusBefore - heavy.fired).toBe(22); + }, 120000); + + /** + * **The two regions where it fires nothing are a result, not a blank.** Both + * `[0,0]` and `[-1200,800]` have ore, and the port places cliffs across both; + * the rule simply finds no overlap there. That is consistent with `#94`'s + * finding that at real settings the port places nothing in the `[0,0]` blob at + * all - the blob is only reachable when a sweep forces a contour through the + * ore field - and it is why the rule's whole measurable value sits at + * `[1500,1500]`. + */ + it("fires nowhere at the two regions whose surplus is already 1-2 cells", () => { + for (const x0 of [0, -1200]) { + const c = regionCase(x0); + const placed = placedWithoutOreRule(c.region); + expect(placed.length).toBeGreaterThan(280); + const surplus = placed.filter((p) => !gameCells(c.cliffs).has(key(p.x, p.y))).length; + expect(surplus).toBeLessThanOrEqual(2); + } + }, 120000); +}); + +/** + * **Why the shipped variant is the one it is** - three defaults, each a + * measurement rather than a preference. + * + * `#88`/`#90` already paid for the lesson this table exists to avoid: the + * best-scoring collision model was the WRONG one, because it scored well by + * absorbing an unrelated defect. So both alternatives are scored here and left + * in the record, rather than dismissed in a comment. + */ +describe("the variants that were rejected, and by how much", () => { + const suppressedTruth = (): Set => { + const a = (label: string): Arm => { + const c = (direction.cases as unknown as Arm[]).find((k) => k.label === label); + if (c === undefined) throw new Error(`no arm ${label}`); + return c; + }; + const on = gameCells(a("entity region, resources ON").cliffs); + const off = gameCells(a("entity region, ALL resources OFF").cliffs); + return new Set([...off].filter((k) => !on.has(k))); + }; + + it("scores base vs per-orientation box, with and without the geyser", () => { + const c = regionCase(1500); + const game = gameCells(c.cliffs); + const truth = suppressedTruth(); + const placed = placedWithoutOreRule(c.region); + + const score = (box: "base" | "orientation", includeGeyser: boolean) => { + const reject = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls, { + box, + includeGeyser, + geyserAt, + }); + const fired = placed.filter((p) => reject(p.code, p.x, p.y)); + return { + fired: fired.length, + correct: fired.filter((p) => truth.has(key(p.x, p.y))).length, + falseRejections: fired.filter((p) => game.has(key(p.x, p.y))).length, + }; + }; + + expect(truth.size).toBe(31); + // SHIPPED. The only variant that costs no recall at all. + expect(score("base", false)).toEqual({ fired: 20, correct: 20, falseRejections: 0 }); + // The geyser arm is not merely risky, it is strictly HARMFUL here: one more + // false rejection and not one additional correct suppression. Its placements + // are salt-dependent (46-63 over eight salts against the game's 56) and its + // box is 14x the ores', so a geyser in the wrong place sweeps a wide area. + expect(score("base", true)).toEqual({ fired: 21, correct: 20, falseRejections: 1 }); + // The per-orientation box catches one MORE true cell - and pays two kept + // cliffs for it. Higher `correct` is exactly the trap: recall is the half + // that must not be traded, so this loses despite the better headline. + expect(score("orientation", false)).toEqual({ fired: 23, correct: 21, falseRejections: 2 }); + expect(score("orientation", true)).toEqual({ fired: 24, correct: 21, falseRejections: 3 }); + }, 120000); + + /** + * **The answer to `#99`'s open question, as a number.** Driving the rejection + * from the port's own ore model instead of the game's entities costs exactly + * one cell: the fixture-driven geometry explains 21 of the 31, the port-driven + * one 20. So the port's ore footprint is a faithful substitute here, which is + * what made it safe to ship the rule at all. + */ + it("costs exactly one cell against driving it from the game's own entities", () => { + const c = regionCase(1500); + const truth = suppressedTruth(); + const placed = placedWithoutOreRule(c.region); + const reject = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); + const portDriven = placed.filter((p) => reject(p.code, p.x, p.y) && truth.has(key(p.x, p.y))); + + // 21 is `test/cliffOreDirection.spec.ts`'s figure for the same geometry run + // against the game's resource entities. Re-derived here rather than quoted, + // so this cannot drift away from that spec silently. + const arm = (direction.cases as unknown as Arm[]).find( + (k) => k.label === "entity region, resources ON", + ); + if (arm === undefined) throw new Error("no ON arm"); + const [l, t, r, b] = VULCANUS_CLIFF_BASE_COLLISION_BOX; + const fixtureDriven = [...truth].filter((k) => { + const [xs, ys] = k.split(","); + const cx = Number(xs); + const cy = Number(ys); + return arm.resources.some((p) => { + const h = p.name === "sulfuric-acid-geyser" ? 1.3984375 : VULCANUS_ORE_COLLISION_HALF; + return cx + l < p.x + h && p.x - h < cx + r && cy + t < p.y + h && p.y - h < cy + b; + }); + }).length; + + expect(fixtureDriven).toBe(21); + expect(portDriven.length).toBe(20); + }, 120000); + + /** + * **The gap stays tracked rather than tuned away.** 11 of the 31 the game + * suppresses are not reproduced: 10 are `#99`'s run remainders (every one of + * the six connected components of the suppressed set contains a directly + * overlapped cell, so they are the tails of runs whose interior was rejected) + * and 1 is the cell the port's ore model misses against the game's entities. + * + * Widening the box until all 31 fall out is available and deliberately not + * done - see the module comment on `vulcanusOreRejection.ts`. + */ + it("pins the unexplained remainder at 11", () => { + const c = regionCase(1500); + const truth = suppressedTruth(); + const placed = placedWithoutOreRule(c.region); + const reject = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); + const explained = new Set( + placed.filter((p) => reject(p.code, p.x, p.y)).map((p) => key(p.x, p.y)), + ); + expect([...truth].filter((k) => !explained.has(k)).length).toBe(11); + }, 120000); +}); + +/** + * The two properties that make the predicate's cheapness safe, and the disable + * path. + */ +describe("the predicate itself", () => { + /** + * **The tile window is derived, so it is guarded rather than trusted.** The + * predicate does not enumerate entities: it solves the two rectangles for the + * tiles whose centres can possibly overlap, which is 2 tiles for an ore. A + * brute-force scan a tile wider on every side, testing the overlap explicitly, + * must agree on every cell - otherwise the closed form is dropping hits. + */ + it("agrees with a brute-force scan one tile wider on every side", () => { + const c = regionCase(1500); + const placed = placedWithoutOreRule(c.region); + const reject = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); + const oreAt = makeVulcanusOreFootprint(resources, ctx.vulcanusResourceControls); + const [l, t, r, b] = VULCANUS_CLIFF_BASE_COLLISION_BOX; + const h = VULCANUS_ORE_COLLISION_HALF; + + const wide = (x: number, y: number): boolean => { + for (let tx = Math.floor(x + l - h - 0.5) - 1; tx <= Math.ceil(x + r + h - 0.5) + 1; tx++) + for (let ty = Math.floor(y + t - h - 0.5) - 1; ty <= Math.ceil(y + b + h - 0.5) + 1; ty++) { + const px = tx + 0.5; + const py = ty + 0.5; + if (x + l < px + h && px - h < x + r && y + t < py + h && py - h < y + b && oreAt(tx, ty)) + return true; + } + return false; + }; + + expect(placed.length).toBe(900); + expect(placed.filter((p) => wide(p.x, p.y) !== reject(p.code, p.x, p.y)).length).toBe(0); + // Non-vacuity: the brute-force arm really does find the same 20, so "0 + // disagreements" is not two predicates both returning false everywhere. + expect(placed.filter((p) => wide(p.x, p.y)).length).toBe(20); + }, 120000); + + /** + * **A disabled ore suppresses nothing**, which is not a bolted-on special case + * but the very lever the game was driven with to establish the rule (`size = 0` + * on `autoplace_controls`, `#99`). It is also the app's own behaviour: a user + * who turns an ore off must not keep seeing cliffs missing where it was. + */ + it("fires zero times when every resource control is disabled", () => { + const c = regionCase(1500); + const placed = placedWithoutOreRule(c.region); + const off = withCtxDefaults({ + ...INPUT, + vulcanusResourceControls: { + tungstenOre: { frequency: 1, size: 0 }, + calcite: { frequency: 1, size: 0 }, + vulcanusCoal: { frequency: 1, size: 0 }, + sulfuricAcidGeyser: { frequency: 1, size: 0 }, + }, + }); + const reject = makeVulcanusOreRejection(resources, off.vulcanusResourceControls); + expect(placed.filter((p) => reject(p.code, p.x, p.y)).length).toBe(0); + }, 120000); + + /** + * **The two overlays must agree on where the ore is.** The cliff rejection and + * the ore overlay now share `RESOURCE_PROBABILITY_THRESHOLD`, but sharing a + * constant is not the same as painting the same footprint. This renders the + * ore overlay onto a blank image and checks pixel for pixel that an ore- + * coloured pixel is exactly where the rejection's footprint predicate says ore + * is - so a cliff can never be suppressed by ore the user cannot see. + */ + it("suppresses against exactly the footprint the ore overlay paints", () => { + // The full oracle region, not a corner of it: a 64x64 window at this origin + // contains no ore at all, so the comparison came back vacuously equal. The + // `painted > 0` assertion below is what caught that. + const w = 256; + const h = 256; + const img = { width: w, height: h, data: new Uint8ClampedArray(w * h * 4) } as ImageData; + renderVulcanusResources(img, { seed0: INPUT.seed0, originX: 1500, originY: 1500, ctx: INPUT }); + + const oreColors = new Set(["98,86,149", "204,179,179", "0,0,0"]); + const oreAt = makeVulcanusOreFootprint(resources, ctx.vulcanusResourceControls); + let painted = 0; + let mismatches = 0; + for (let py = 0; py < h; py++) + for (let px = 0; px < w; px++) { + const o = (py * w + px) * 4; + const opaque = img.data[o + 3] === 255; + const isOre = + opaque && + oreColors.has( + `${String(img.data[o])},${String(img.data[o + 1])},${String(img.data[o + 2])}`, + ); + if (isOre) painted++; + if (isOre !== oreAt(1500 + px, 1500 + py)) mismatches++; + } + + expect(mismatches).toBe(0); + // Non-vacuity: this window really does contain ore, so a zero above is an + // agreement rather than two empty sets. + expect(painted).toBeGreaterThan(0); + }, 120000); +}); From 318f532aa5bb6742b3dd4aceaa476ee805d2eb31 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 2 Aug 2026 15:10:06 -0700 Subject: [PATCH 2/5] test(cliffs): the budget FLIPPED - recall is now the bigger defect (#84) Every cliff defect found since #18 has been a rule the port over-places without (lava collision, the rotbb box shape, the ore suppression), so "find another rejection" has been the shape of the work throughout. After #100 that is no longer where the error is. | region | surplus | missing | lava-killed | ore-killed | never generated | | --- | --- | --- | --- | --- | --- | | [0,0] | 2 | 2 | 2 | 0 | 0 | | [1500,1500] | 22 | 27 | 3 | 0 | 24 | | [-1200,800] | 1 | 15 | 1 | 0 | 14 | | total | 25 | 44 | 6 | 0 | 38 | **The port now misses more cells than it over-places, 44 to 25**, and 38 of the 44 are cells the crossings stage never produces at all - a different defect in a different part of the port from everything solved so far. `[0,0]` generates every cell the game does: its whole miss is the two the lava rejection took, and `neverGenerated` is zero there. All 38 sit in the two far-field regions, which agrees with #93 finding the port exact at [0,0] and [-1200,800] at cliff_smoothing = 0 and still wrong at [1500,1500]. Also closes item 3 (the entity half of `Surface::wouldCollide`) UNPORTED, by size rather than by difficulty - the same move that retired fixImpossibleCells as a suspect. It is a rejection, and rejections can only remove cells: total surplus is 25, which bounds what rocks and craters together could ever be worth against a 44-cell recall gap they cannot touch. The crater arm is settled exactly, since craters are already in the fixtures: all 8 sit in [-1200,800] and not one touches a cell the port over-places, nor any cliff the game kept. Worth zero. The rock arm needs no fixture - the ceiling covers it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c --- docs/noise/vulcanus-cliffs-NOTES.md | 47 ++++++ test/cliffErrorBudget.spec.ts | 237 ++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 test/cliffErrorBudget.spec.ts diff --git a/docs/noise/vulcanus-cliffs-NOTES.md b/docs/noise/vulcanus-cliffs-NOTES.md index 120913b0..95eb47e6 100644 --- a/docs/noise/vulcanus-cliffs-NOTES.md +++ b/docs/noise/vulcanus-cliffs-NOTES.md @@ -1623,3 +1623,50 @@ an ore, 4x3 for a geyser, against the lava rejection's ~30), and it reuses the composite's `VulcanusStack.resources` rather than building a second DAG. The derived window is guarded by a brute-force scan a tile wider on every side, not trusted. + +## The budget FLIPPED: recall is now the bigger defect, and item 3 is closed by size (#84, 2026-08-02) + +Every cliff defect found since #18 has been a rule the port over-places without - +lava collision, the rotbb box shape, the ore suppression - so "find another +rejection" has been the shape of the work throughout. **After the ore rejection +landed that is no longer where the error is.** `test/cliffErrorBudget.spec.ts` +splits it: + +| region | surplus | missing | lava-killed | ore-killed | **never generated** | +| --- | --- | --- | --- | --- | --- | +| `[0,0]` | 2 | 2 | 2 | 0 | **0** | +| `[1500,1500]` | 22 | 27 | 3 | 0 | **24** | +| `[-1200,800]` | 1 | 15 | 1 | 0 | **14** | +| **total** | **25** | **44** | 6 | 0 | **38** | + +**The port now misses more cells than it over-places, 44 to 25** - and 38 of the +44 are cells the crossings stage never produces at all, which is a different +defect in a different part of the port from everything solved so far. The other 6 +are cells our own lava rejection removed and the game kept. + +### `[0,0]` generates everything the game does + +Its entire miss is the two cells the lava rejection took; `neverGenerated` is +**zero**. All 38 sit in the two far-field regions. That is a sharp regional +signature and the strongest open lead - and it agrees with #93, which found the +port exact at `[0,0]` and `[-1200,800]` with `cliff_smoothing = 0` and still +wrong at `[1500,1500]`, so the two are probably not one defect. + +### Item 3 (the entity half of `Surface::wouldCollide`) is CLOSED, unported + +Not because it is hard - because of its size, the same move that retired +`fixImpossibleCells` as a suspect (35 cells against a 175-cell effect). + +**It is a rejection, and rejections can only remove cells.** Total surplus across +all three regions is 25, so 25 bounds what the entire entity half - rocks, +craters, everything - could ever be worth, against a 44-cell recall gap it cannot +touch and could only widen. + +The crater arm is settled exactly, because craters are already in the fixtures: +all 8 sit in `[-1200,800]` and **not one touches a cell the port over-places** +(nor any cliff the game kept). Worth exactly zero. The rock arm has no fixture +and does not need one - the ceiling argument covers it. + +**Do not port it without first fixing recall**, and if it is ever ported, score +`missing` alongside `surplus` or it will look like an improvement while making +the port worse. diff --git a/test/cliffErrorBudget.spec.ts b/test/cliffErrorBudget.spec.ts new file mode 100644 index 00000000..5d32cc29 --- /dev/null +++ b/test/cliffErrorBudget.spec.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vite-plus/test"; + +import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; +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_BASE_COLLISION_BOX } 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)}`; + +interface Ent { + x: number; + y: number; + name: 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 resources = buildResources(ctx); +const oreRejects = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); +const tileCollides = (x: number, y: number): boolean => + VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); +const BANDS = { + elevation0: VULCANUS_CLIFF_ELEVATION_0, + interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, + smoothing: VULCANUS_CLIFF_SMOOTHING, +}; + +const cellsFor = (r: Case["region"], opts: { lava?: boolean; ore?: boolean }): Set => + new Set( + makeCliffPlacementFromFields(fields, { + ...BANDS, + tileCollides: opts.lava === true ? tileCollides : undefined, + cellRejects: opts.ore === true ? oreRejects : undefined, + }) + .placedCells(r.x0, r.y0, r.x1, r.y1) + .map((p) => key(p.x, p.y)), + ); + +interface Budget { + at: string; + surplus: number; + missing: number; + lavaKilled: number; + oreKilled: number; + neverGenerated: number; +} + +const budget = (c: Case): Budget => { + const r = c.region; + const game = new Set( + c.cliffs.filter((e) => e.name === "cliff-vulcanus").map((e) => key(e.x, e.y)), + ); + const raw = cellsFor(r, {}); + const lava = cellsFor(r, { lava: true }); + const full = cellsFor(r, { lava: true, ore: true }); + const missing = [...game].filter((k) => !full.has(k)); + return { + at: key(r.x0, r.y0), + surplus: [...full].filter((k) => !game.has(k)).length, + missing: missing.length, + lavaKilled: missing.filter((k) => raw.has(k) && !lava.has(k)).length, + oreKilled: missing.filter((k) => lava.has(k) && !full.has(k)).length, + neverGenerated: missing.filter((k) => !raw.has(k)).length, + }; +}; + +/** + * **Where the remaining Vulcanus cliff error actually is**, split so that the + * next piece of work is chosen by size rather than by which lead reads best. + * + * This exists because the ore rejection (#100) changed the answer. Every cliff + * defect found since #18 has been a rule the port OVER-places without - lava + * collision, the rotbb box shape, the ore suppression - so "find another + * rejection" has been the shape of the work throughout. After #100 that is no + * longer where the budget is: **the port now misses more cells than it + * over-places**, and no rejection rule can ever fix a missed cell. + * + * The split below is the whole point. `missing` decomposes into cells one of our + * own rejections killed (so the rejection is too aggressive) and cells the + * crossings stage **never produced at all** - which is a completely different + * defect, in a different part of the port. + */ +describe("the remaining error budget, by region and by cause", () => { + it("pins the composition", () => { + const budgets = (entities.cases as unknown as Case[]).map(budget); + + expect(budgets).toEqual([ + { + at: "0,0", + surplus: 2, + missing: 2, + lavaKilled: 2, + oreKilled: 0, + neverGenerated: 0, + }, + { + at: "1500,1500", + surplus: 22, + missing: 27, + lavaKilled: 3, + oreKilled: 0, + neverGenerated: 24, + }, + { + at: "-1200,800", + surplus: 1, + missing: 15, + lavaKilled: 1, + oreKilled: 0, + neverGenerated: 14, + }, + ]); + + const sum = (f: (b: Budget) => number): number => budgets.reduce((a, b) => a + f(b), 0); + // The headline the rest of this file is about: MISSING now outweighs + // SURPLUS, 44 to 25, and 38 of the 44 are cells we never generate. + expect(sum((b) => b.surplus)).toBe(25); + expect(sum((b) => b.missing)).toBe(44); + expect(sum((b) => b.neverGenerated)).toBe(38); + expect(sum((b) => b.lavaKilled)).toBe(6); + // The ore rule kills nothing the game kept, in any region - the gate #100 + // shipped under, re-asserted here against the full pipeline rather than the + // predicate in isolation. + expect(sum((b) => b.oreKilled)).toBe(0); + }, 120000); + + /** + * **`[0,0]` generates every cell the game does.** Its entire miss is two cells + * our own lava rejection removed; `neverGenerated` is zero. The other two + * regions account for all 38. + * + * That is a sharp regional signature and the strongest lead this file + * produces: whatever fails to generate those 38 cells does not fail near + * spawn. It is also consistent with #93, which found the port exact at `[0,0]` + * and `[-1200,800]` with `cliff_smoothing = 0` and still wrong at + * `[1500,1500]` - so the two are probably not one defect. + */ + it("localises the never-generated cells away from spawn", () => { + const budgets = (entities.cases as unknown as Case[]).map(budget); + const spawn = budgets.find((b) => b.at === "0,0"); + expect(spawn?.neverGenerated).toBe(0); + expect(spawn?.missing).toBe(spawn?.lavaKilled); + // Non-vacuity: `[0,0]` is not a region where nothing happens - the raw pass + // produces 292 cells there and the lava rejection removes 9 of them. + const r = (entities.cases as unknown as Case[])[0].region; + expect(cellsFor(r, {}).size).toBe(292); + expect(cellsFor(r, { lava: true }).size).toBe(283); + }, 120000); +}); + +/** + * **Item 3 of #84 - the entity half of `Surface::wouldCollide` - is closed by + * size, before any of it is ported.** + * + * `#94` established that cliffs get TWO collision tests and the port implements + * one: `applyCliffs` re-tests through `Surface::wouldCollide`, which is + * `constCollideWithTile` AND `collideWithEntity`. `big-volcanic-rock`, + * `huge-volcanic-rock` and `crater-cliff` all share a layer with the cliff mask, + * so all three can reject a cliff, and none of it is ported. + * + * It is still not worth porting, for a reason that has nothing to do with how + * hard it is: **it is a rejection, and rejections can only remove cells.** The + * entire surplus across all three oracle regions is 25 cells, so 25 is the + * absolute ceiling on what the whole entity half could ever be worth - against a + * 44-cell recall gap it would leave untouched and could only make worse. + * + * This is the same "close a candidate by SIZE first" move that retired + * `fixImpossibleCells` as a suspect (35 cells against a 175-cell effect). + */ +describe("the entity collision half is bounded before it is built", () => { + /** + * The crater arm can be settled exactly, because craters are already in the + * fixtures - and it is worth **zero**. All 8 sit in `[-1200,800]`, and not one + * of them touches a cell the port over-places. + */ + it("craters explain none of the surplus", () => { + const [l, t, r, b] = VULCANUS_CLIFF_BASE_COLLISION_BOX; + let cratersSeen = 0; + let touchingSurplus = 0; + + for (const c of entities.cases as unknown as Case[]) { + const game = new Set( + c.cliffs.filter((e) => e.name === "cliff-vulcanus").map((e) => key(e.x, e.y)), + ); + const craters = c.cliffs.filter((e) => e.name === "crater-cliff"); + cratersSeen += craters.length; + const surplus = [...cellsFor(c.region, { lava: true, ore: true })].filter( + (k) => !game.has(k), + ); + for (const k of surplus) { + const [xs, ys] = k.split(","); + const cx = Number(xs); + const cy = Number(ys); + // Two cliff-shaped boxes overlap when their centres are within the sum + // of the half-extents; `crater-cliff` carries the same box as + // `cliff-vulcanus` (both `+/-0.988 x +/-0.488` in the fixture protos). + if (craters.some((q) => Math.abs(q.x - cx) < r - l && Math.abs(q.y - cy) < b - t)) + touchingSurplus++; + } + } + + // Non-vacuity: there really are craters to have found, they simply do not + // coincide with any cell the port gets wrong. + expect(cratersSeen).toBe(8); + expect(touchingSurplus).toBe(0); + }, 120000); + + /** + * The rock arm cannot be settled from the fixtures - no oracle capture carries + * `big-volcanic-rock` / `huge-volcanic-rock` - but it does not need to be. The + * ceiling below bounds the entire entity half, rocks included: a rejection + * cannot place a cell, so it can never touch the 44 the port is missing. + */ + it("bounds the whole entity half at 25 cells, against a 44-cell recall gap", () => { + const budgets = (entities.cases as unknown as Case[]).map(budget); + const surplus = budgets.reduce((a, b) => a + b.surplus, 0); + const missing = budgets.reduce((a, b) => a + b.missing, 0); + expect(surplus).toBe(25); + expect(missing).toBeGreaterThan(surplus); + }, 120000); +}); From 3be1ed9739835c279c61ce44fd50c481fe208f00 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 2 Aug 2026 16:06:21 -0700 Subject: [PATCH 3/5] test(cliffs): the recall gap was a QUERY-WINDOW ARTIFACT - recall is 0.9961 (#84) Supersedes this branch's first commit, whose central claim was wrong. `find_entities_filtered` selects entities whose BOUNDING BOX touches the query area; `placedCells` emits cells whose CENTRE lies inside it. Different inclusion rules, so the fixtures carry cliffs centred just outside the box and every one has been scored as a miss. | region | game rows | centred inside | centred OUTSIDE | | --- | --- | --- | --- | | [0,0] | 283 | 283 | 0 | | [1500,1500] | 885 | 861 | 24 | | [-1200,800] | 401 | 387 | 14 | That is 38 cells - the entire apparent recall gap - and the port places 38 of 38 once the query box includes their centres. Every one is an agreement being scored as a failure. The widening arm is the load-bearing one: "we never looked there" alone is equally consistent with the port being wrong. Corrected budget, both sides scored alike: | region | game | port | matched | surplus | missing | | --- | --- | --- | --- | --- | --- | | [0,0] | 283 | 283 | 281 | 2 | 2 | | [1500,1500] | 861 | 880 | 858 | 22 | 3 | | [-1200,800] | 387 | 387 | 386 | 1 | 1 | | total | 1531 | 1550 | 1525 | 25 | 6 | **Recall 0.9961, precision 0.9839.** The 0.972 recall in the notes divided the same 1525 matches by 1569 rather than 1531 - the match count was never wrong, only the denominator. All 6 missing cells are ones our own lava rejection removed; there is no cell the port simply fails to generate. So precision is the only real defect left. Consequently item 3 (the entity half of Surface::wouldCollide) is RE-OPENED. The earlier commit closed it by size, arguing a rejection cannot help a 44-cell recall gap - that argument died with the gap. With recall at 0.9961 the dominant defect is the 25 surplus cells, which is exactly what a rejection removes. The crater arm stays settled at zero (all 8 sit in [-1200,800], none touches a surplus cell). The rock arm has no oracle capture at all, so capturing one is the next step - now with a 25-cell target rather than a ceiling against it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c --- docs/noise/vulcanus-cliffs-NOTES.md | 107 +++++---- test/cliffErrorBudget.spec.ts | 339 +++++++++++++++------------- 2 files changed, 244 insertions(+), 202 deletions(-) diff --git a/docs/noise/vulcanus-cliffs-NOTES.md b/docs/noise/vulcanus-cliffs-NOTES.md index 95eb47e6..56f583fa 100644 --- a/docs/noise/vulcanus-cliffs-NOTES.md +++ b/docs/noise/vulcanus-cliffs-NOTES.md @@ -45,6 +45,20 @@ > the three regions, measured as the boolean `crossesCliff` reads rather than as > a value. That closes the clamp-vacuity worry properly. > +> ## UPDATE 4, 2026-08-02: RECALL IS 0.9961 - every recall figure below is wrong +> +> **Read `## The recall gap was a QUERY-WINDOW ARTIFACT` (last section) before +> quoting any accuracy number in this file.** The port was scored against game +> entity lists that include cliffs centred OUTSIDE the captured box, because +> `find_entities_filtered` selects by bounding box and `placedCells` emits by +> centre. That is 38 cells, the whole apparent recall gap, and the port places +> **38 of 38** once asked about their centres. +> +> Corrected: **recall 0.9961, precision 0.9839**, 1525 matched of 1531. The match +> count was never wrong - only the denominator. All 6 remaining missing cells are +> ones our own lava rejection removed, so **precision (25 surplus) is the only +> real defect left**, and item 3 is re-OPENED as the leading candidate for it. +> > ## UPDATE 3, 2026-08-02: the ore rule is PORTED and SHIPS > > The last section, **`## The rule is PORTED, and driving it from our own ore @@ -1624,49 +1638,56 @@ composite's `VulcanusStack.resources` rather than building a second DAG. The derived window is guarded by a brute-force scan a tile wider on every side, not trusted. -## The budget FLIPPED: recall is now the bigger defect, and item 3 is closed by size (#84, 2026-08-02) +## The recall gap was a QUERY-WINDOW ARTIFACT - recall is 0.9961, not 0.972 (#84, 2026-08-02) + +**The port has been scored against a game set it was never asked to reproduce.** +`find_entities_filtered` returns every entity whose BOUNDING BOX touches the +query area; `placedCells` emits every cell whose CENTRE lies inside it. Those are +different inclusion rules, so the fixtures carry cliffs centred just outside the +box, and every one has been counted as a miss. + +| region | game rows | centred inside | centred OUTSIDE | +| --- | --- | --- | --- | +| `[0,0]` | 283 | 283 | **0** | +| `[1500,1500]` | 885 | 861 | **24** | +| `[-1200,800]` | 401 | 387 | **14** | + +That is 38 cells - the entire apparent recall gap - and **the port places 38 of +38 of them** once the query box is widened to include their centres. Every one is +an agreement that was being scored as a failure. `test/cliffErrorBudget.spec.ts` +pins both arms; the widening arm is the load-bearing one, since "we never looked +there" alone is equally consistent with the port being wrong. -Every cliff defect found since #18 has been a rule the port over-places without - -lava collision, the rotbb box shape, the ore suppression - so "find another -rejection" has been the shape of the work throughout. **After the ore rejection -landed that is no longer where the error is.** `test/cliffErrorBudget.spec.ts` -splits it: +### The corrected budget, both sides scored alike -| region | surplus | missing | lava-killed | ore-killed | **never generated** | +| region | game | port | matched | surplus | missing | | --- | --- | --- | --- | --- | --- | -| `[0,0]` | 2 | 2 | 2 | 0 | **0** | -| `[1500,1500]` | 22 | 27 | 3 | 0 | **24** | -| `[-1200,800]` | 1 | 15 | 1 | 0 | **14** | -| **total** | **25** | **44** | 6 | 0 | **38** | - -**The port now misses more cells than it over-places, 44 to 25** - and 38 of the -44 are cells the crossings stage never produces at all, which is a different -defect in a different part of the port from everything solved so far. The other 6 -are cells our own lava rejection removed and the game kept. - -### `[0,0]` generates everything the game does - -Its entire miss is the two cells the lava rejection took; `neverGenerated` is -**zero**. All 38 sit in the two far-field regions. That is a sharp regional -signature and the strongest open lead - and it agrees with #93, which found the -port exact at `[0,0]` and `[-1200,800]` with `cliff_smoothing = 0` and still -wrong at `[1500,1500]`, so the two are probably not one defect. - -### Item 3 (the entity half of `Surface::wouldCollide`) is CLOSED, unported - -Not because it is hard - because of its size, the same move that retired -`fixImpossibleCells` as a suspect (35 cells against a 175-cell effect). - -**It is a rejection, and rejections can only remove cells.** Total surplus across -all three regions is 25, so 25 bounds what the entire entity half - rocks, -craters, everything - could ever be worth, against a 44-cell recall gap it cannot -touch and could only widen. - -The crater arm is settled exactly, because craters are already in the fixtures: -all 8 sit in `[-1200,800]` and **not one touches a cell the port over-places** -(nor any cliff the game kept). Worth exactly zero. The rock arm has no fixture -and does not need one - the ceiling argument covers it. - -**Do not port it without first fixing recall**, and if it is ever ported, score -`missing` alongside `surplus` or it will look like an improvement while making -the port worse. +| `[0,0]` | 283 | 283 | 281 | 2 | 2 | +| `[1500,1500]` | 861 | 880 | 858 | 22 | 3 | +| `[-1200,800]` | 387 | 387 | 386 | 1 | 1 | +| **total** | **1531** | **1550** | **1525** | **25** | **6** | + +**Recall 0.9961, precision 0.9839.** The 0.972 recall quoted in this file's +banner and throughout #84 divided the same 1525 matches by 1569 instead of 1531. +**Correct every recall figure you find here before acting on it** - the match +count was never wrong, only the denominator. + +**All 6 missing cells are ones our own lava rejection removed.** There is no cell +left that the port simply fails to generate, in any region. So precision is the +remaining defect, 25 against 6. + +### Consequence: item 3 stays OPEN, and the crater arm is worth zero + +An earlier draft of this section closed item 3 (the entity half of +`Surface::wouldCollide`) by size, arguing a rejection can only remove cells and +so could not help a 44-cell recall gap. **That argument died with the gap.** With +recall at 0.9961 the dominant defect is the 25 surplus cells, which is exactly +what a rejection removes - so the entity half is the leading candidate, not a +closed one. + +The crater arm is still settled exactly, and is worth **nothing**: all 8 craters +sit in `[-1200,800]` and not one touches a cell the port over-places. The rock +arm (`big-volcanic-rock`, `huge-volcanic-rock`) has no oracle capture at all - +no cliff fixture carries anything but `cliff-vulcanus` and `crater-cliff` - so +capturing one is the next concrete step, now with a 25-cell target rather than a +ceiling argument against it. diff --git a/test/cliffErrorBudget.spec.ts b/test/cliffErrorBudget.spec.ts index 5d32cc29..55d4fe86 100644 --- a/test/cliffErrorBudget.spec.ts +++ b/test/cliffErrorBudget.spec.ts @@ -8,8 +8,10 @@ import { VULCANUS_CLIFF_SMOOTHING, makeVulcanusCliffFields, } from "../src/noise/cliffs/vulcanusCliffFields"; -import { makeVulcanusOreRejection } from "../src/noise/cliffs/vulcanusOreRejection"; -import { VULCANUS_CLIFF_BASE_COLLISION_BOX } from "../src/noise/cliffs/vulcanusOreRejection"; +import { + VULCANUS_CLIFF_BASE_COLLISION_BOX, + 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"; @@ -22,8 +24,14 @@ interface Ent { y: number; name: string; } +interface Region { + x0: number; + y0: number; + x1: number; + y1: number; +} interface Case { - region: { x0: number; y0: number; x1: number; y1: number }; + region: Region; cliffs: Ent[]; } @@ -32,7 +40,6 @@ const ctx = withCtxDefaults(INPUT); const fields = makeVulcanusCliffFields(ctx); const tileAt = makeVulcanusTileResolver(INPUT); const resources = buildResources(ctx); -const oreRejects = makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls); const tileCollides = (x: number, y: number): boolean => VULCANUS_CLIFF_BLOCKING_TILES.has(tileAt(x, y).name); const BANDS = { @@ -40,198 +47,212 @@ const BANDS = { interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, smoothing: VULCANUS_CLIFF_SMOOTHING, }; +const SHIPPED = { + ...BANDS, + tileCollides, + cellRejects: makeVulcanusOreRejection(resources, ctx.vulcanusResourceControls), +}; -const cellsFor = (r: Case["region"], opts: { lava?: boolean; ore?: boolean }): Set => +const cases = entities.cases as unknown as Case[]; +const allCliffs = (c: Case): Ent[] => c.cliffs.filter((e) => e.name === "cliff-vulcanus"); +const inBox = (c: Case): Ent[] => + allCliffs(c).filter( + (p) => p.x >= c.region.x0 && p.x < c.region.x1 && p.y >= c.region.y0 && p.y < c.region.y1, + ); +const placed = ( + r: Region, + bands: Parameters[1], + pad = 0, +): Set => new Set( - makeCliffPlacementFromFields(fields, { - ...BANDS, - tileCollides: opts.lava === true ? tileCollides : undefined, - cellRejects: opts.ore === true ? oreRejects : undefined, - }) - .placedCells(r.x0, r.y0, r.x1, r.y1) + makeCliffPlacementFromFields(fields, bands) + .placedCells(r.x0 - pad, r.y0 - pad, r.x1 + pad, r.y1 + pad) .map((p) => key(p.x, p.y)), ); -interface Budget { - at: string; - surplus: number; - missing: number; - lavaKilled: number; - oreKilled: number; - neverGenerated: number; -} +/** + * **The recall gap was a comparison artifact, and it is worth reading how it + * hid.** + * + * `find_entities_filtered` returns every entity whose BOUNDING BOX touches the + * query area; `placedCells` emits every cell whose CENTRE lies inside it. Those + * are different inclusion rules, so the game's list carries cliffs centred just + * outside the box that the port was never asked about - and scoring one against + * the other counts each of them as a miss. + * + * It is worth 38 cells, which is the entire apparent recall gap: + * + * | region | game rows | centred inside | centred OUTSIDE | + * | --- | --- | --- | --- | + * | `[0,0]` | 283 | 283 | **0** | + * | `[1500,1500]` | 885 | 861 | **24** | + * | `[-1200,800]` | 401 | 387 | **14** | + * + * And the port places **38 of 38** of them once the query box is widened enough + * to include their centres - so every one is an agreement being scored as a + * failure. + * + * This is the same failure as #86, where a 187-cell "excess" turned out to be + * 185 cells of a rule only one side was applying. Before believing a gap, + * check both sides are being asked the same question. + */ +describe("the apparent recall gap is a query-window artifact", () => { + it("finds 38 game cliffs centred outside the box they were captured for", () => { + const outside = cases.map((c) => allCliffs(c).length - inBox(c).length); + expect(outside).toEqual([0, 24, 14]); + expect(outside.reduce((a, b) => a + b, 0)).toBe(38); + }); -const budget = (c: Case): Budget => { - const r = c.region; - const game = new Set( - c.cliffs.filter((e) => e.name === "cliff-vulcanus").map((e) => key(e.x, e.y)), - ); - const raw = cellsFor(r, {}); - const lava = cellsFor(r, { lava: true }); - const full = cellsFor(r, { lava: true, ore: true }); - const missing = [...game].filter((k) => !full.has(k)); - return { - at: key(r.x0, r.y0), - surplus: [...full].filter((k) => !game.has(k)).length, - missing: missing.length, - lavaKilled: missing.filter((k) => raw.has(k) && !lava.has(k)).length, - oreKilled: missing.filter((k) => lava.has(k) && !full.has(k)).length, - neverGenerated: missing.filter((k) => !raw.has(k)).length, - }; -}; + /** + * **The decisive arm.** Widening the query so those centres ARE asked about + * places every one of them. Without this the finding would only be "we never + * looked there", which is consistent with the port being wrong as well as with + * it being right. + */ + it("places 38 of 38 once the query box includes their centres", () => { + let found = 0; + let total = 0; + for (const c of cases) { + const inside = new Set(inBox(c).map((p) => key(p.x, p.y))); + const outside = allCliffs(c).filter((p) => !inside.has(key(p.x, p.y))); + const wide = placed(c.region, SHIPPED, 8); + total += outside.length; + found += outside.filter((p) => wide.has(key(p.x, p.y))).length; + } + expect(total).toBe(38); + expect(found).toBe(38); + }, 120000); +}); /** - * **Where the remaining Vulcanus cliff error actually is**, split so that the - * next piece of work is chosen by size rather than by which lead reads best. + * **The corrected budget.** Scored with both sides on the same inclusion rule: + * the game set restricted to cliffs centred in the box, against the pipeline the + * renderer actually runs (lava rejection + ore rejection). * - * This exists because the ore rejection (#100) changed the answer. Every cliff - * defect found since #18 has been a rule the port OVER-places without - lava - * collision, the rotbb box shape, the ore suppression - so "find another - * rejection" has been the shape of the work throughout. After #100 that is no - * longer where the budget is: **the port now misses more cells than it - * over-places**, and no rejection rule can ever fix a missed cell. + * | region | game | port | matched | surplus | missing | + * | --- | --- | --- | --- | --- | --- | + * | `[0,0]` | 283 | 283 | 281 | 2 | 2 | + * | `[1500,1500]` | 861 | 880 | 858 | 22 | 3 | + * | `[-1200,800]` | 387 | 387 | 386 | 1 | 1 | + * | **total** | **1531** | **1550** | **1525** | **25** | **6** | * - * The split below is the whole point. `missing` decomposes into cells one of our - * own rejections killed (so the rejection is too aggressive) and cells the - * crossings stage **never produced at all** - which is a completely different - * defect, in a different part of the port. + * **Recall 0.9961, precision 0.9839.** The long-standing 0.972 recall figure in + * `vulcanus-cliffs-NOTES.md` was this artifact: it divided the same 1525 matches + * by 1569 rather than 1531. */ -describe("the remaining error budget, by region and by cause", () => { - it("pins the composition", () => { - const budgets = (entities.cases as unknown as Case[]).map(budget); - - expect(budgets).toEqual([ - { - at: "0,0", - surplus: 2, - missing: 2, - lavaKilled: 2, - oreKilled: 0, - neverGenerated: 0, - }, - { - at: "1500,1500", - surplus: 22, - missing: 27, - lavaKilled: 3, - oreKilled: 0, - neverGenerated: 24, - }, - { - at: "-1200,800", - surplus: 1, - missing: 15, - lavaKilled: 1, - oreKilled: 0, - neverGenerated: 14, - }, +describe("the remaining error budget, both sides scored alike", () => { + interface Budget { + at: string; + game: number; + port: number; + matched: number; + surplus: number; + missing: number; + lavaKilled: number; + oreKilled: number; + } + + const budget = (c: Case): Budget => { + const game = new Set(inBox(c).map((p) => key(p.x, p.y))); + const raw = placed(c.region, BANDS); + const lava = placed(c.region, { ...BANDS, tileCollides }); + const full = placed(c.region, SHIPPED); + const missing = [...game].filter((k) => !full.has(k)); + const matched = [...full].filter((k) => game.has(k)).length; + return { + at: key(c.region.x0, c.region.y0), + game: game.size, + port: full.size, + matched, + surplus: full.size - matched, + missing: missing.length, + lavaKilled: missing.filter((k) => raw.has(k) && !lava.has(k)).length, + oreKilled: missing.filter((k) => lava.has(k) && !full.has(k)).length, + }; + }; + + it("pins the corrected composition", () => { + const budgets = cases.map(budget); + expect(budgets.map((b) => [b.at, b.game, b.port, b.surplus, b.missing])).toEqual([ + ["0,0", 283, 283, 2, 2], + ["1500,1500", 861, 880, 22, 3], + ["-1200,800", 387, 387, 1, 1], ]); const sum = (f: (b: Budget) => number): number => budgets.reduce((a, b) => a + f(b), 0); - // The headline the rest of this file is about: MISSING now outweighs - // SURPLUS, 44 to 25, and 38 of the 44 are cells we never generate. + expect(sum((b) => b.matched)).toBe(1525); + expect(sum((b) => b.game)).toBe(1531); + expect(sum((b) => b.port)).toBe(1550); expect(sum((b) => b.surplus)).toBe(25); - expect(sum((b) => b.missing)).toBe(44); - expect(sum((b) => b.neverGenerated)).toBe(38); + expect(sum((b) => b.missing)).toBe(6); + + // Every missing cell is one OUR OWN lava rejection removed. There is no + // cell left that the port simply fails to generate. expect(sum((b) => b.lavaKilled)).toBe(6); - // The ore rule kills nothing the game kept, in any region - the gate #100 - // shipped under, re-asserted here against the full pipeline rather than the - // predicate in isolation. expect(sum((b) => b.oreKilled)).toBe(0); + + expect(sum((b) => b.matched) / sum((b) => b.game)).toBeCloseTo(0.9961, 4); + expect(sum((b) => b.matched) / sum((b) => b.port)).toBeCloseTo(0.9839, 4); }, 120000); /** - * **`[0,0]` generates every cell the game does.** Its entire miss is two cells - * our own lava rejection removed; `neverGenerated` is zero. The other two - * regions account for all 38. - * - * That is a sharp regional signature and the strongest lead this file - * produces: whatever fails to generate those 38 cells does not fail near - * spawn. It is also consistent with #93, which found the port exact at `[0,0]` - * and `[-1200,800]` with `cliff_smoothing = 0` and still wrong at - * `[1500,1500]` - so the two are probably not one defect. + * **So precision is the remaining defect, not recall** - 25 surplus cells + * against 6 missing, and the 6 are all attributable to one rule we already + * implement being slightly too aggressive rather than to anything unported. */ - it("localises the never-generated cells away from spawn", () => { - const budgets = (entities.cases as unknown as Case[]).map(budget); - const spawn = budgets.find((b) => b.at === "0,0"); - expect(spawn?.neverGenerated).toBe(0); - expect(spawn?.missing).toBe(spawn?.lavaKilled); - // Non-vacuity: `[0,0]` is not a region where nothing happens - the raw pass - // produces 292 cells there and the lava rejection removes 9 of them. - const r = (entities.cases as unknown as Case[])[0].region; - expect(cellsFor(r, {}).size).toBe(292); - expect(cellsFor(r, { lava: true }).size).toBe(283); + it("leaves precision as the dominant defect", () => { + const budgets = cases.map(budget); + const surplus = budgets.reduce((a, b) => a + b.surplus, 0); + const missing = budgets.reduce((a, b) => a + b.missing, 0); + expect(surplus).toBeGreaterThan(missing * 4); }, 120000); }); /** - * **Item 3 of #84 - the entity half of `Surface::wouldCollide` - is closed by - * size, before any of it is ported.** - * - * `#94` established that cliffs get TWO collision tests and the port implements - * one: `applyCliffs` re-tests through `Surface::wouldCollide`, which is - * `constCollideWithTile` AND `collideWithEntity`. `big-volcanic-rock`, - * `huge-volcanic-rock` and `crater-cliff` all share a layer with the cliff mask, - * so all three can reject a cliff, and none of it is ported. + * **Item 3 of #84 - the entity half of `Surface::wouldCollide` - stays OPEN, and + * the crater arm is worth zero.** * - * It is still not worth porting, for a reason that has nothing to do with how - * hard it is: **it is a rejection, and rejections can only remove cells.** The - * entire surplus across all three oracle regions is 25 cells, so 25 is the - * absolute ceiling on what the whole entity half could ever be worth - against a - * 44-cell recall gap it would leave untouched and could only make worse. + * An earlier draft of this file closed item 3 by size, reasoning that a + * rejection can only remove cells and so could not help a 44-cell recall gap. + * That reasoning died with the gap: recall is 0.9961 and the dominant defect is + * now the 25 surplus cells, which is exactly what a rejection removes. The + * entity half is therefore the leading candidate rather than a closed one. * - * This is the same "close a candidate by SIZE first" move that retired - * `fixImpossibleCells` as a suspect (35 cells against a 175-cell effect). + * The crater arm can still be settled exactly, and it is worth nothing. */ -describe("the entity collision half is bounded before it is built", () => { - /** - * The crater arm can be settled exactly, because craters are already in the - * fixtures - and it is worth **zero**. All 8 sit in `[-1200,800]`, and not one - * of them touches a cell the port over-places. - */ - it("craters explain none of the surplus", () => { +describe("the entity collision half: craters are worth zero, rocks are the lead", () => { + it("finds no crater touching any cell the port over-places", () => { const [l, t, r, b] = VULCANUS_CLIFF_BASE_COLLISION_BOX; - let cratersSeen = 0; - let touchingSurplus = 0; - - for (const c of entities.cases as unknown as Case[]) { - const game = new Set( - c.cliffs.filter((e) => e.name === "cliff-vulcanus").map((e) => key(e.x, e.y)), - ); - const craters = c.cliffs.filter((e) => e.name === "crater-cliff"); - cratersSeen += craters.length; - const surplus = [...cellsFor(c.region, { lava: true, ore: true })].filter( - (k) => !game.has(k), - ); - for (const k of surplus) { + let craters = 0; + let touching = 0; + + for (const c of cases) { + const game = new Set(inBox(c).map((p) => key(p.x, p.y))); + const cr = c.cliffs.filter((e) => e.name === "crater-cliff"); + craters += cr.length; + for (const k of [...placed(c.region, SHIPPED)].filter((s) => !game.has(s))) { const [xs, ys] = k.split(","); const cx = Number(xs); const cy = Number(ys); - // Two cliff-shaped boxes overlap when their centres are within the sum - // of the half-extents; `crater-cliff` carries the same box as - // `cliff-vulcanus` (both `+/-0.988 x +/-0.488` in the fixture protos). - if (craters.some((q) => Math.abs(q.x - cx) < r - l && Math.abs(q.y - cy) < b - t)) - touchingSurplus++; + // `crater-cliff` carries the same box as `cliff-vulcanus` in the fixture + // protos, so two of them overlap within the summed half-extents. + if (cr.some((q) => Math.abs(q.x - cx) < r - l && Math.abs(q.y - cy) < b - t)) touching++; } } - // Non-vacuity: there really are craters to have found, they simply do not - // coincide with any cell the port gets wrong. - expect(cratersSeen).toBe(8); - expect(touchingSurplus).toBe(0); + // Non-vacuity: there really are craters to have found. + expect(craters).toBe(8); + expect(touching).toBe(0); }, 120000); /** - * The rock arm cannot be settled from the fixtures - no oracle capture carries - * `big-volcanic-rock` / `huge-volcanic-rock` - but it does not need to be. The - * ceiling below bounds the entire entity half, rocks included: a rejection - * cannot place a cell, so it can never touch the 44 the port is missing. + * The rock arm is unmeasurable from the fixtures - no oracle capture carries + * `big-volcanic-rock` / `huge-volcanic-rock` - so capturing one is the next + * concrete step, and it now has a 25-cell target to aim at rather than a + * ceiling argument against it. */ - it("bounds the whole entity half at 25 cells, against a 44-cell recall gap", () => { - const budgets = (entities.cases as unknown as Case[]).map(budget); - const surplus = budgets.reduce((a, b) => a + b.surplus, 0); - const missing = budgets.reduce((a, b) => a + b.missing, 0); - expect(surplus).toBe(25); - expect(missing).toBeGreaterThan(surplus); - }, 120000); + it("records that no fixture carries the rock entities the arm needs", () => { + const names = new Set(cases.flatMap((c) => c.cliffs.map((e) => e.name))); + expect([...names].sort()).toEqual(["cliff-vulcanus", "crater-cliff"]); + }); }); From c063a197284e66104e73b63bb15477b49a586ad2 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 2 Aug 2026 16:09:41 -0700 Subject: [PATCH 4/5] test(cliffs): the rock arm fails on the mechanism's own geometry (#84) Completes the item 3 thread in this branch rather than opening a second PR for it, since the previous commit had just re-opened it. No rock capture is needed to kill it. `computeInternal` runs `generateCliffs` before `generateEntities`, and `apply` runs `applyCliffs` (+124) before `applyEntities` (+164), so within a chunk no rock exists when the cliff is applied. A rock can only block a cliff from an ALREADY-GENERATED NEIGHBOUR, which confines the whole mechanism to cells near a 32-tile chunk border. | | n | near chunk border | | --- | --- | --- | | surplus | 25 | 11 = 44.0% | | matched | 1525 | 673 = 44.1% | The base rate to three significant figures. The surplus has no chunk-border character at all, so the one geometry the mechanism is confined to is not where the errors are. The direct overlap test agrees and is deliberately the weaker arm: 3 of 25 surplus cells overlap a modelled rock against a 6.6% base rate (~1.7 expected), which is nothing - and our rock placement is a salt-dependent roll whose individual positions are unreliable exactly as the geyser's were in #100. So item 3 explains approximately none of the 25, and is closed on the mechanism's geometry rather than on the ceiling argument that died with the recall gap. Remaining unexplained: 25 surplus, 6 missing (all lava-rejection over-rejections), 33 wrong orientations. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c --- docs/noise/vulcanus-cliffs-NOTES.md | 36 +++++++++++--- test/cliffErrorBudget.spec.ts | 74 ++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/docs/noise/vulcanus-cliffs-NOTES.md b/docs/noise/vulcanus-cliffs-NOTES.md index 56f583fa..10783655 100644 --- a/docs/noise/vulcanus-cliffs-NOTES.md +++ b/docs/noise/vulcanus-cliffs-NOTES.md @@ -57,7 +57,9 @@ > Corrected: **recall 0.9961, precision 0.9839**, 1525 matched of 1531. The match > count was never wrong - only the denominator. All 6 remaining missing cells are > ones our own lava rejection removed, so **precision (25 surplus) is the only -> real defect left**, and item 3 is re-OPENED as the leading candidate for it. +> real defect left**. Item 3 (rocks/craters) is then CLOSED on the mechanism's own +> geometry: it can only act across chunk borders, and the surplus sits at borders +> at 44.0% against the matched cells' 44.1% - the base rate exactly. > > ## UPDATE 3, 2026-08-02: the ore rule is PORTED and SHIPS > @@ -1686,8 +1688,30 @@ what a rejection removes - so the entity half is the leading candidate, not a closed one. The crater arm is still settled exactly, and is worth **nothing**: all 8 craters -sit in `[-1200,800]` and not one touches a cell the port over-places. The rock -arm (`big-volcanic-rock`, `huge-volcanic-rock`) has no oracle capture at all - -no cliff fixture carries anything but `cliff-vulcanus` and `crater-cliff` - so -capturing one is the next concrete step, now with a 25-cell target rather than a -ceiling argument against it. +sit in `[-1200,800]` and not one touches a cell the port over-places. + +### And then the rock arm failed too - on the mechanism's own geometry + +**No rock capture is needed to kill it.** `computeInternal` runs +`generateCliffs` before `generateEntities`, and `apply` runs `applyCliffs` +(`+124`) before `applyEntities` (`+164`), so within a chunk no rock exists when +the cliff is applied. A rock can only ever block a cliff from an +ALREADY-GENERATED NEIGHBOUR - which confines the entire mechanism to cells near a +32-tile chunk border. + +| | n | near chunk border | +| --- | --- | --- | +| surplus | 25 | 11 = **44.0%** | +| matched | 1525 | 673 = **44.1%** | + +**The base rate to three significant figures.** The surplus has no chunk-border +character at all, so the one geometry this mechanism is confined to is not where +the errors are. The direct overlap test agrees and is the weaker arm (3 of 25 +against a 6.6% base rate, ~1.7 expected - nothing, and our rock placement is a +salt-dependent roll whose individual positions are unreliable exactly as the +geyser's were in #100). + +**So item 3 explains approximately none of the 25 surplus cells, and is closed - +this time on the mechanism's geometry rather than on the ceiling argument that +died with the recall gap.** What remains unexplained: 25 surplus, 6 missing (all +lava-rejection over-rejections), and the 33 wrong orientations. diff --git a/test/cliffErrorBudget.spec.ts b/test/cliffErrorBudget.spec.ts index 55d4fe86..f435467f 100644 --- a/test/cliffErrorBudget.spec.ts +++ b/test/cliffErrorBudget.spec.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from "vite-plus/test"; import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; import { makeCliffPlacementFromFields } from "../src/noise/cliffs/cliffPlacement"; +import { + CLIFF_CELL_CENTER_X, + CLIFF_CELL_CENTER_Y, + CLIFF_GRID_SIZE, +} from "../src/noise/cliffs/cliffCatalog"; import { VULCANUS_CLIFF_ELEVATION_0, VULCANUS_CLIFF_ELEVATION_INTERVAL, @@ -245,14 +250,71 @@ describe("the entity collision half: craters are worth zero, rocks are the lead" expect(touching).toBe(0); }, 120000); - /** - * The rock arm is unmeasurable from the fixtures - no oracle capture carries - * `big-volcanic-rock` / `huge-volcanic-rock` - so capturing one is the next - * concrete step, and it now has a 25-cell target to aim at rather than a - * ceiling argument against it. - */ it("records that no fixture carries the rock entities the arm needs", () => { const names = new Set(cases.flatMap((c) => c.cliffs.map((e) => e.name))); expect([...names].sort()).toEqual(["cliff-vulcanus", "crater-cliff"]); }); + + /** + * **The rock arm fails a test that does not depend on our rock model at all.** + * + * `computeInternal` runs `generateCliffs` before `generateEntities`, and + * `apply` runs `applyCliffs` (`+124`) before `applyEntities` (`+164`) - so + * within a chunk no rock exists when the cliff is applied. A rock can only + * ever block a cliff from an ALREADY-GENERATED NEIGHBOUR, which confines the + * whole mechanism to cells near a 32-tile chunk border. + * + * The port's surplus cells sit near a chunk border at **44.0%**, against + * **44.1%** for the cells it gets right. That is the base rate to three + * significant figures: the surplus has no chunk-border character whatever, so + * the one geometry this mechanism is confined to is not where the errors are. + * + * The direct overlap test agrees and is the weaker arm, which is why it is not + * leaned on: 3 of 25 surplus cells overlap a modelled rock against a 6.6% base + * rate, i.e. ~1.7 expected. Three against 1.7 is nothing (and our rock + * placement is a salt-dependent roll, so individual positions are unreliable + * exactly as the geyser's were in #100). + * + * **So item 3 explains approximately none of the 25.** It is closed a second + * time - but on the mechanism's own geometry rather than on the ceiling + * argument that died with the recall gap, and without needing a rock capture. + */ + it("finds no chunk-border character in the surplus, which is where rocks must act", () => { + const nearBorder = (x: number, y: number): boolean => { + const cx = (x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE; + const cy = (y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE; + const ix = ((cx % 8) + 8) % 8; + const iy = ((cy % 8) + 8) % 8; + return ix === 0 || ix === 7 || iy === 0 || iy === 7; + }; + + let surplus = 0; + let surplusBorder = 0; + let matched = 0; + let matchedBorder = 0; + for (const c of cases) { + const game = new Set(inBox(c).map((p) => key(p.x, p.y))); + for (const k of placed(c.region, SHIPPED)) { + const [xs, ys] = k.split(","); + const border = nearBorder(Number(xs), Number(ys)); + if (game.has(k)) { + matched++; + if (border) matchedBorder++; + } else { + surplus++; + if (border) surplusBorder++; + } + } + } + + expect(surplus).toBe(25); + expect(matched).toBe(1525); + // Within a percentage point of each other - no enrichment at all. + const sRate = surplusBorder / surplus; + const mRate = matchedBorder / matched; + expect(Math.abs(sRate - mRate)).toBeLessThan(0.02); + // Non-vacuity: "near a border" is a real subset, not everything or nothing. + expect(mRate).toBeGreaterThan(0.3); + expect(mRate).toBeLessThan(0.6); + }, 120000); }); From ec8f08bc40ad2c92f449b610e333a13b0f1c870b Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 2 Aug 2026 16:16:16 -0700 Subject: [PATCH 5/5] test(cliffs): the orientation residual is not a boundary tie (#84) Stacked on the error-budget branch. The residual's known shape - exactly one edge per wrong cell, always an OVER-detection - has an obvious cheap explanation that turns out to be wrong, and ruling it out eliminates a whole class of cause. `crossesCliff` decides on the sign of `elevation - boundary`. If an endpoint sat within float noise of a band boundary, the ~1e-6 our fields agree to would flip the crossing, and the residual would be an irreducible precision limit with nothing to fix. Measured: every crossing edge in a wrong cell sits at least 0.205 from its boundary, median ~9.9 - four to seven orders of magnitude clear of float noise. For the game to disagree its elevation must differ from ours by more than 0.2, which is a real field or rule difference. Non-vacuity: the overall minimum across all 2,920 crossing edges is 6.4e-3, thirty times tighter, so "far from the boundary" is a property of the wrong cells and not of the sample. Also pins WHICH channel the corner fixture holds, as a number rather than as prose: against our per-tile elevation the worst corner differs by 4.8e-2; against the grid-4 cliff channel the generator actually reads, by 96.09. #83 was the most expensive mistake this subsystem has made and prose is not a guard. The gap that leaves is the point: the grid-4 cliff-elevation channel has NO per-corner oracle, is the only placement input never checked against the game corner by corner, and after the margin result is the only remaining candidate that could move an endpoint the required 0.2. Capturing it is the next step - and not via calculate_tile_properties, which is the 1-tile program that produced the wrong-channel fixture in the first place. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c --- docs/noise/vulcanus-cliffs-NOTES.md | 42 ++++++ test/cliffOrientationMargin.spec.ts | 213 ++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 test/cliffOrientationMargin.spec.ts diff --git a/docs/noise/vulcanus-cliffs-NOTES.md b/docs/noise/vulcanus-cliffs-NOTES.md index 10783655..cd63c5a1 100644 --- a/docs/noise/vulcanus-cliffs-NOTES.md +++ b/docs/noise/vulcanus-cliffs-NOTES.md @@ -1715,3 +1715,45 @@ geyser's were in #100). this time on the mechanism's geometry rather than on the ceiling argument that died with the recall gap.** What remains unexplained: 25 surplus, 6 missing (all lava-rejection over-rejections), and the 33 wrong orientations. + +## The orientation residual is NOT a boundary tie - and the cliff channel still has no oracle (#84, 2026-08-02) + +`cliffOrientationResidual.spec.ts` pins the shape: every wrong cell differs in +exactly ONE edge, always an OVER-detection (game finds no crossing, port finds +one). That shape has an obvious cheap explanation, and it is worth writing down +that it is **wrong**, because it eliminates a whole class of cause. + +`crossesCliff` decides on the SIGN of `elevation - boundary`. If an endpoint sat +within float noise of a band boundary, the ~1e-6 our fields agree to would flip +the crossing, the residual would be an irreducible precision limit, and there +would be nothing to fix. + +**Measured: every crossing edge in a wrong cell sits at least 0.205 from its +boundary**, median ~9.9 - four to seven orders of magnitude clear of float +noise. For the game to disagree, its elevation there must differ from ours by +more than 0.2. That is a real field or rule difference. + +Non-vacuity: the overall minimum across all 2,920 crossing edges is 6.4e-3, +thirty times tighter, so "far from the boundary" is a property of the wrong +cells rather than of the sample. `test/cliffOrientationMargin.spec.ts`. + +### The one input never checked corner-by-corner + +`oracle-vulcanus-cliff-corner-fields-entity-regions` holds the **tile** channel. +That is stated in prose at the top of `vulcanusCliffCornerFields.spec.ts`, and +is now asserted as a number: against our per-tile elevation the worst corner +differs by **4.8e-2**; against the grid-4 cliff channel the cliff generator +actually reads, by **96.09**. + +So **the grid-4 cliff-elevation channel has no per-corner oracle at all.** It is +the only input to the placement rule never checked against the game corner by +corner, and after the margin result it is also the only remaining candidate that +could move an endpoint the required 0.2. + +**Capturing it is the next concrete step**, and it is not a plain +`calculate_tile_properties` dump - that is the 1-tile program and is exactly what +produced the wrong-channel fixture. It needs the cliff-channel value routed out +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. diff --git a/test/cliffOrientationMargin.spec.ts b/test/cliffOrientationMargin.spec.ts new file mode 100644 index 00000000..cfb4d54f --- /dev/null +++ b/test/cliffOrientationMargin.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vite-plus/test"; + +import corners from "./fixtures/oracle-vulcanus-cliff-corner-fields-entity-regions.seed123456.json"; +import entities from "./fixtures/oracle-vulcanus-cliff-entities.seed123456.json"; +import { + crossesCliff, + makeCliffPlacementFromFields, + smoothingKnots, +} from "../src/noise/cliffs/cliffPlacement"; +import { + CLIFF_CELL_CENTER_X, + CLIFF_CELL_CENTER_Y, + CLIFF_CODE_TO_ORIENTATION, + CLIFF_GRID_SIZE, + CLIFF_ORIENTATION_NAMES, +} from "../src/noise/cliffs/cliffCatalog"; +import { + VULCANUS_CLIFF_ELEVATION_0, + VULCANUS_CLIFF_ELEVATION_INTERVAL, + VULCANUS_CLIFF_SMOOTHING, + makeVulcanusCliffFields, +} from "../src/noise/cliffs/vulcanusCliffFields"; +import { makeVulcanusStack } from "../src/noise/tiles/vulcanusCatalog"; +import { withCtxDefaults } from "../src/noise/eval/ctx"; + +interface Ent { + x: number; + y: number; + name: string; + orientation?: string | null; +} +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 E0 = VULCANUS_CLIFF_ELEVATION_0; +const INTERVAL = VULCANUS_CLIFF_ELEVATION_INTERVAL; +const S = VULCANUS_CLIFF_SMOOTHING; + +const rawE = new Map(); +const rawElev = (i: number, j: number): number => { + const k = `${String(i)},${String(j)}`; + let v = rawE.get(k); + if (v === undefined) { + v = fields.cliffElevation(i * CLIFF_GRID_SIZE, j * CLIFF_GRID_SIZE); + rawE.set(k, v); + } + return v; +}; +const elevAt = (i: number, j: number): number => { + const kx = smoothingKnots(i); + const ky = smoothingKnots(j); + const bil = + (1 - kx.t) * (1 - ky.t) * rawElev(kx.lo, ky.lo) + + kx.t * (1 - ky.t) * rawElev(kx.hi, ky.lo) + + (1 - kx.t) * ky.t * rawElev(kx.lo, ky.hi) + + kx.t * ky.t * rawElev(kx.hi, ky.hi); + return S === 1 ? bil : (1 - S) * rawElev(i, j) + S * bil; +}; +interface Corner { + elev: number; + cliff: number; +} +const corner = (i: number, j: number): Corner => ({ + elev: elevAt(i, j), + cliff: fields.cliffiness(i * CLIFF_GRID_SIZE, j * CLIFF_GRID_SIZE), +}); + +/** How far the two endpoints sit from the band boundary `crossesCliff` uses. */ +const margin = (a: number, b: number): number => { + const boundary = E0 + INTERVAL * Math.floor((Math.max(a, b) - E0) / INTERVAL); + return Math.min(Math.abs(a - boundary), Math.abs(b - boundary)); +}; + +const edgesOf = (cx: number, cy: number): [Corner, Corner][] => { + const a = corner(cx, cy); + const b = corner(cx, cy + 1); + const d = corner(cx + 1, cy); + const f = corner(cx + 1, cy + 1); + return [ + [a, b], + [d, f], + [a, d], + [b, f], + ]; +}; + +const crossingMarginsIn = (cx: number, cy: number): number[] => + edgesOf(cx, cy) + .filter(([u, v]) => crossesCliff(u.elev, v.elev, (u.cliff + v.cliff) / 2, E0, INTERVAL) !== 0) + .map(([u, v]) => margin(u.elev, v.elev)); + +/** + * **The orientation residual is not a floating-point tie at the band boundary.** + * + * `test/cliffOrientationResidual.spec.ts` pins the residual's shape: every wrong + * cell differs from the game in exactly ONE edge, and it is always an + * OVER-detection - the game finds no crossing there and the port finds one. + * + * That shape has an obvious cheap explanation which turns out to be wrong, and + * ruling it out is worth a spec because it eliminates a whole class of cause. + * `crossesCliff` decides by the SIGN of `elevation - boundary` on each endpoint, + * so if an endpoint sat within float noise of a band boundary, a difference of + * 1e-6 between our field and the game's would flip the crossing - and the port's + * fields agree with the game's to about that order. Under that story the residual + * would be an irreducible precision limit and there would be nothing to fix. + * + * **It is not that.** Every crossing edge in a wrong cell sits at least **0.2** + * from its boundary, with a median near 10 - four to seven orders of magnitude + * clear of float noise. For the game to disagree, its elevation at that corner + * must differ from ours by more than 0.2, which is a real field difference, not + * a rounding one. + * + * So the residual is a genuine disagreement about a value or a rule, and it is + * worth continuing to hunt. + */ +describe("the orientation over-detections are not boundary ties", () => { + const wrongCellMargins: number[] = []; + const allCrossingMargins: number[] = []; + + for (const c of entities.cases as unknown as Case[]) { + const r = c.region; + const byPos = new Map(); + for (const e of c.cliffs) + if (e.name === "cliff-vulcanus" && typeof e.orientation === "string") + byPos.set(`${String(e.x)},${String(e.y)}`, e.orientation); + + for (const p of makeCliffPlacementFromFields(fields, { + elevation0: E0, + interval: INTERVAL, + smoothing: S, + }).placedCells(r.x0, r.y0, r.x1, r.y1)) { + const gameOrient = byPos.get(`${String(p.x)},${String(p.y)}`); + if (gameOrient === undefined) continue; + const cx = (p.x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE; + const cy = (p.y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE; + const ms = crossingMarginsIn(cx, cy); + allCrossingMargins.push(...ms); + const id = CLIFF_CODE_TO_ORIENTATION[p.code]; + const oursName = id === undefined ? "?" : CLIFF_ORIENTATION_NAMES[id]; + if (oursName !== gameOrient) wrongCellMargins.push(...ms); + } + } + + it("compares a real population, not a handful", () => { + // ~37 wrong cells carrying two crossing edges each, against every crossing + // edge of every matched cell. + expect(wrongCellMargins.length).toBeGreaterThan(50); + expect(allCrossingMargins.length).toBeGreaterThan(2000); + }, 120000); + + it("puts every crossing edge in a wrong cell far from its band boundary", () => { + const min = Math.min(...wrongCellMargins); + // Measured 0.205. Asserted as a bound rather than the exact value so a field + // change that keeps the conclusion does not fail the spec spuriously. + expect(min).toBeGreaterThan(0.1); + // Four orders of magnitude clear of the ~1e-6 the fields agree to. + expect(min).toBeGreaterThan(1e-4 * 1000); + }, 120000); + + /** + * Non-vacuity, and it matters here: the bound above would be unremarkable if + * NO edge anywhere sat near a boundary. Some do - the overall minimum is about + * 6e-3, thirty times tighter than the worst wrong cell - so "far from the + * boundary" is a property of the wrong cells rather than of the sample. + */ + it("is a property of the wrong cells, not of every edge", () => { + expect(Math.min(...allCrossingMargins)).toBeLessThan(Math.min(...wrongCellMargins) / 10); + }, 120000); +}); + +/** + * **The corner fixture is the TILE channel, and this pins it so.** + * + * `test/vulcanusCliffCornerFields.spec.ts` says so in prose at the top, and its + * substitution deliberately feeds `vulcanus_elevation` into `cliffElevation` to + * preserve the history of how the wrong channel stayed hidden. Prose is not a + * guard, and this is the single most expensive mistake this subsystem has made + * (#83) - so the identification is asserted here as a number. + * + * The gap it leaves is the important part: **the grid-4 cliff-elevation channel + * has no per-corner oracle at all.** It is the one input to the placement rule + * that has never been checked against the game corner by corner, and after the + * measurement above it is also the only remaining candidate that could move an + * endpoint by the required 0.2. Capturing it is the next concrete step. + */ +describe("which elevation channel the corner fixture holds", () => { + it("matches the per-tile channel and NOT the grid-4 cliff channel", () => { + const stack = makeVulcanusStack(INPUT); + const cliffFields = makeVulcanusCliffFields(stack.ctx, stack); + const keys = corners.corners; + const elev = corners.elevation; + + let maxVsTile = 0; + let maxVsCliff = 0; + for (let i = 0; i < keys.length; i++) { + const [is, js] = keys[i].split(","); + const x = Number(is) * CLIFF_GRID_SIZE; + const y = Number(js) * CLIFF_GRID_SIZE; + maxVsTile = Math.max(maxVsTile, Math.abs(stack.elevation.elevation(x, y) - elev[i])); + maxVsCliff = Math.max(maxVsCliff, Math.abs(cliffFields.cliffElevation(x, y) - elev[i])); + } + + expect(keys.length).toBe(12675); + // Measured: 4.8e-2 against the tile channel, 96.09 against the cliff channel. + expect(maxVsTile).toBeLessThan(0.1); + expect(maxVsCliff).toBeGreaterThan(50); + }, 120000); +});