Skip to content

Commit 0dc3b82

Browse files
wormeymanclaude
andauthored
feat(cliffs): port the ORE -> CLIFF rejection (#84 item 1) (#100)
#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. Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 97420aa commit 0dc3b82

8 files changed

Lines changed: 861 additions & 5 deletions

File tree

docs/noise/vulcanus-cliffs-NOTES.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@
4545
> the three regions, measured as the boolean `crossesCliff` reads rather than as
4646
> a value. That closes the clamp-vacuity worry properly.
4747
>
48+
> ## UPDATE 3, 2026-08-02: the ore rule is PORTED and SHIPS
49+
>
50+
> The last section, **`## The rule is PORTED, and driving it from our own ore
51+
> model costs one cell`**, supersedes the previous one's closing warning not to
52+
> port this against our own resource positions without measuring that arm. The
53+
> arm is measured: **zero false rejections across all three oracle regions**, and
54+
> `[1500,1500]`'s surplus falls 42 -> 22 (precision 0.953 -> 0.975) with recall
55+
> untouched. It ships as `CliffBands.cellRejects`, ores only, base collision box.
56+
> The geyser arm and the per-orientation box were both scored and both LOSE - see
57+
> the table there before re-proposing either. 11 of the 31 remain unexplained and
58+
> the box is deliberately not widened to cover them.
59+
>
4860
> ## UPDATE, 2026-08-02: the FIELD is exonerated; the residual is two defects
4961
>
5062
> 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
15341546
resource entities as the input, which isolates the rule from the accuracy of the
15351547
resource port; driving it from `renderVulcanusResources` is a second question and
15361548
has not been measured.
1549+
1550+
## The rule is PORTED, and driving it from our own ore model costs one cell (#84 item 1, 2026-08-02)
1551+
1552+
The section above closes with "driving it from `renderVulcanusResources` is a
1553+
second question and has not been measured." It is measured now, and the answer
1554+
is that it is safe: `test/cliffOreRejection.spec.ts` scores the shipped
1555+
predicate - `makeVulcanusOreRejection`, driven off `buildResources`, the same
1556+
field stack the ore overlay paints from - across all three oracle regions.
1557+
1558+
| region | game | port placed | fires | **false rejections** | surplus |
1559+
| --- | --- | --- | --- | --- | --- |
1560+
| `[0,0]` | 283 | 283 | 0 | 0 | 2 -> 2 |
1561+
| `[1500,1500]` | 885 | 900 | 20 | **0** | 42 -> **22** |
1562+
| `[-1200,800]` | 401 | 387 | 0 | 0 | 1 -> 1 |
1563+
1564+
Precision at `[1500,1500]` goes 0.953 -> **0.975** with the 858 true positives
1565+
untouched. **Recall is not touched anywhere**, which was the gate: this rule may
1566+
only ever cost precision, and a cell removed that the game kept would be the one
1567+
outcome worth refusing.
1568+
1569+
Driving it from our own ore model rather than the game's entities costs exactly
1570+
**one** cell - the fixture-driven geometry explains 21 of the 31, the port-driven
1571+
one 20. That is the whole price of the substitution the section above flagged.
1572+
1573+
### Three variants were scored, and the two that lose are in the spec
1574+
1575+
Not dismissed in a comment, because #88/#90 already paid for that lesson here -
1576+
the best-scoring collision model was the wrong one.
1577+
1578+
| variant | fires | correct of 31 | false rejections |
1579+
| --- | --- | --- | --- |
1580+
| **base box, ores only (SHIPPED)** | 20 | 20 | **0** |
1581+
| base box + geyser | 21 | 20 | 1 |
1582+
| per-orientation box | 23 | 21 | 2 |
1583+
| per-orientation + geyser | 24 | 21 | 3 |
1584+
1585+
- **The geyser arm is strictly harmful**, not merely risky: one more false
1586+
rejection and *not one* additional correct suppression. Its placements are
1587+
salt-dependent (46-63 over eight salts against the game's 56) and its box is
1588+
14x the ores', so a geyser in the wrong place sweeps a wide area. It is
1589+
implemented behind `includeGeyser`, defaulting off, so the arm stays scored
1590+
rather than deleted.
1591+
- **The per-orientation rotbb box catches one MORE true cell and pays two kept
1592+
cliffs for it.** Higher `correct` is exactly the trap: recall is the half that
1593+
must not be traded. Note this means the ore rule and the lava rejection use
1594+
*different* cliff rectangles - the base `collision_box` and the per-orientation
1595+
one respectively - which is only defensible because the ore mechanism is open
1596+
and the base box is the shape it was measured with. If the mechanism is ever
1597+
found, revisit this first.
1598+
1599+
### What is NOT claimed
1600+
1601+
**11 of the 31 are still unexplained** and the box is deliberately not widened
1602+
until they fall out: 10 are #99's run remainders and 1 is the cell our ore model
1603+
misses. `test/cliffOreRejection.spec.ts` pins that 11 so the gap stays tracked.
1604+
1605+
The **mechanism is still open**. This ships a characterised empirical rule -
1606+
one-way, additive, local, box-shaped - and the disassembly still says cliffs are
1607+
computed and placed before any resource entity exists, so whatever the engine is
1608+
really doing, it is not the collision test this models.
1609+
1610+
### Where it lives
1611+
1612+
`CliffBands.cellRejects`, a second optional per-cell predicate beside
1613+
`tileCollides` in `cliffPlacement.ts`, applied at the same site. It is
1614+
deliberately opaque - the shared cliff core stays planet-agnostic, and a
1615+
planet-specific, mechanism-open rule does not leak into it. It hangs there rather
1616+
than filtering `placedCells`' output so that **the model the specs score is the
1617+
model the renderer ships**; every spec drives `makeCliffPlacementFromFields`
1618+
directly, so a filter further out would score a different thing than it renders.
1619+
1620+
Two cheapnesses worth knowing: the predicate never enumerates entities (it solves
1621+
the two rectangles for the tiles whose centres can overlap - exactly 2 tiles for
1622+
an ore, 4x3 for a geyser, against the lava rejection's ~30), and it reuses the
1623+
composite's `VulcanusStack.resources` rather than building a second DAG. The
1624+
derived window is guarded by a brute-force scan a tile wider on every side, not
1625+
trusted.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Porting the ORE -> CLIFF rejection (issue #84, item 1)
2+
3+
**Date:** 2026-08-02
4+
**Status:** implemented, measured, shipped on `feat/cliff-ore-rejection`
5+
6+
> Point-in-time design record, per `docs/superpowers/specs/` convention. Not a
7+
> living document - the current state of the rule lives in
8+
> `docs/noise/vulcanus-cliffs-NOTES.md` and `src/noise/cliffs/vulcanusOreRejection.ts`.
9+
10+
## Problem
11+
12+
PR #99 settled that Vulcanus resources suppress cliffs (`ORE -> CLIFF`, not the
13+
reverse) and characterised the rule as one-way, additive, local, and shaped like
14+
a box overlap against the resource entity's rectangle. It scored 21 of 31
15+
suppressed cells with zero false alarms in 885.
16+
17+
It deliberately stopped short of porting it, flagging one open sub-question:
18+
19+
> whether driving it from `renderVulcanusResources` (rather than the game's
20+
> entities, which is what the 31/0 score uses) is accurate enough
21+
22+
That is this work.
23+
24+
## Decisions, and what settled each
25+
26+
Four forks. Each was settled by a measurement taken during implementation, not
27+
by preference - the numbers are in `test/cliffOreRejection.spec.ts`.
28+
29+
| decision | chosen | what settled it |
30+
| --- | --- | --- |
31+
| geyser included? | **no**, behind `includeGeyser` | strictly harmful: +1 false rejection, +0 correct |
32+
| cliff rectangle | **prototype base box** | per-orientation catches +1 true cell and costs 2 kept cliffs |
33+
| the unexplained 10 | **not tuned away** | pinned as a gap; #88/#90's lesson |
34+
| where the predicate hangs | **`CliffBands.cellRejects`** | keeps the scored model and the shipped model identical |
35+
36+
### Why recall is the gate
37+
38+
The rule can only ever remove cells. Every cell it fires on at `[1500,1500]` is
39+
surplus, so it is pure precision gain - unless it removes a cliff the game kept,
40+
which is a false rejection and costs recall. Recall is the expensive half of this
41+
port (1.000/0.973/0.965), so "zero false rejections" is a hard gate, not a
42+
report. Both rejected variants fail exactly that gate.
43+
44+
## Architecture
45+
46+
```
47+
CliffBands.cellRejects?: (code, x, y) => boolean // cliffPlacement.ts, beside tileCollides
48+
^ supplied by
49+
makeVulcanusOreRejection(resources, controls, opts) // cliffs/vulcanusOreRejection.ts
50+
^ wired by
51+
renderVulcanusCliffs // reads VulcanusStack.resources
52+
```
53+
54+
`cellRejects` is applied in both paths of `placedCells` (chunked and unchunked),
55+
after the bounds test and after `tileCollides`. It is deliberately an opaque
56+
predicate: the cliff core is planet-agnostic engine behaviour, and this rule is
57+
neither planet-agnostic nor engine-confirmed.
58+
59+
**It hangs there rather than filtering `placedCells`' output** because every spec
60+
drives `makeCliffPlacementFromFields` directly. A filter applied further out
61+
would mean the specs score an unfiltered model while the renderer ships a
62+
filtered one - the same class of silent divergence as the
63+
`worker-configuration.d.ts` drift.
64+
65+
Like `tileCollides` it is a pure per-cell post-filter with no effect on
66+
neighbours, so worker tiling stays byte-identical for free.
67+
68+
### The predicate
69+
70+
- **Cliff box**: `+/-0.98828125 x +/-0.48828125`, the prototype `collision_box`
71+
the fixture carries - *not* the per-orientation rotbb the lava rejection uses.
72+
- **Ore footprint**: `makeVulcanusOreFootprint`, sharing
73+
`RESOURCE_PROBABILITY_THRESHOLD` with the ore overlay so the two cannot drift
74+
onto different footprints. A control at `size = 0` occupies nothing, which is
75+
the same lever the game was driven with.
76+
- **No entity enumeration.** The tiles whose centres can overlap follow in closed
77+
form from the two rectangles: exactly 2 tiles for an ore, 4x3 for a geyser,
78+
against the lava rejection's ~30. The derivation is guarded by a brute-force
79+
scan a tile wider on every side, not trusted.
80+
- Reuses the composite's `VulcanusStack.resources`; `memoXY` is single-entry, so
81+
a private DAG would share nothing and pay for the whole tree again.
82+
83+
## Results
84+
85+
| region | game | placed | fires | false rejections | surplus |
86+
| --- | --- | --- | --- | --- | --- |
87+
| `[0,0]` | 283 | 283 | 0 | 0 | 2 -> 2 |
88+
| `[1500,1500]` | 885 | 900 | 20 | **0** | 42 -> **22** |
89+
| `[-1200,800]` | 401 | 387 | 0 | 0 | 1 -> 1 |
90+
91+
Precision at `[1500,1500]`: 0.953 -> **0.975**, true positives untouched. Driving
92+
from the port's own ore model instead of the game's entities costs exactly one
93+
cell (20 against 21).
94+
95+
## What is deliberately not claimed
96+
97+
- **The mechanism is open.** The disassembly still says cliffs are computed and
98+
placed before any resource entity exists, so this is a characterised empirical
99+
rule, not a port of a known engine path. Both the module comment and the notes
100+
say so.
101+
- **11 of 31 are unexplained** (10 run remainders + 1 our ore model misses) and
102+
the box is not widened until they fall out.
103+
- **The two cliff rectangles now disagree** - base box for ore, per-orientation
104+
for lava. Defensible only while the ore mechanism is open; revisit first if it
105+
is ever found.
106+
107+
## Testing
108+
109+
`test/cliffOreRejection.spec.ts`, 8 tests. Scores the shipped predicate across
110+
all three oracle regions; scores both rejected variants so the choice is a
111+
record rather than an assumption; pins the remainder at 11; guards the derived
112+
tile window; asserts the disable path fires zero times; and cross-checks that the
113+
rejection's footprint equals the ore overlay's painted pixels.
114+
115+
Two non-vacuity guards earned their keep: the footprint cross-check was
116+
initially vacuous on a 64x64 window containing no ore, and `painted > 0` caught
117+
it.

src/noise/cliffs/cliffPlacement.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,28 @@ export interface CliffBands {
114114
* placed cell, and only for cells that are actually placed.
115115
*/
116116
readonly tileCollides?: (x: number, y: number) => boolean;
117+
/**
118+
* An additional per-cell rejection, called with the cell's crossing `code` and
119+
* its centre, for cells that survive the bounds test and `tileCollides`.
120+
* Return `true` to drop the cell.
121+
*
122+
* **Deliberately opaque.** This module is planet-agnostic - the corner
123+
* lattice, `crossesCliff` and the orientation table are engine behaviour - and
124+
* the one rule that currently uses this hook is not engine behaviour at all
125+
* but a characterised empirical one (Vulcanus's ORE -> CLIFF suppression, see
126+
* `vulcanusOreRejection.ts`). Keeping it a bare predicate is what stops a
127+
* planet-specific and mechanism-open rule from leaking into the shared core.
128+
*
129+
* It runs at the same site as `tileCollides` rather than as a filter over
130+
* `placedCells`' return value so that the model the specs score is the model
131+
* the renderer ships; every spec here drives `makeCliffPlacementFromFields`
132+
* directly, so a filter applied further out would score a different thing than
133+
* it renders.
134+
*
135+
* Like `tileCollides` this is a pure post-filter on the emit loop - it cannot
136+
* affect a neighbouring cell, so it leaves worker tiling byte-identical.
137+
*/
138+
readonly cellRejects?: (code: number, x: number, y: number) => boolean;
117139
}
118140

119141
/** Cells per chunk axis: a 32-tile chunk over the 4-tile placement grid. */
@@ -313,6 +335,7 @@ export function makeCliffPlacementFromFields(
313335
const { elevation0: e0, interval } = bands;
314336
const smoothing = bands.smoothing ?? 0;
315337
const tileCollides = bands.tileCollides;
338+
const cellRejects = bands.cellRejects;
316339

317340
/**
318341
* `tryToAddCliff`'s rejection, as a predicate on an already-placed cell: scan
@@ -480,6 +503,7 @@ export function makeCliffPlacementFromFields(
480503
// overhangs the query box.
481504
if (x < x0 || x >= x1 || y < y0 || y >= y1) continue;
482505
if (rejected(code, x, y)) continue;
506+
if (cellRejects?.(code, x, y) === true) continue;
483507
result.push({ x, y, code });
484508
}
485509
}
@@ -508,6 +532,7 @@ export function makeCliffPlacementFromFields(
508532
const y = cy * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y;
509533
if (x < x0 || x >= x1 || y < y0 || y >= y1) continue;
510534
if (rejected(code, x, y)) continue;
535+
if (cellRejects?.(code, x, y) === true) continue;
511536
result.push({ x, y, code });
512537
}
513538
}

0 commit comments

Comments
 (0)