Skip to content

feat(noise): port Factorio's VoronoiNoise primitive, exact at f32 (#27) - #160

Merged
wormeyman merged 29 commits into
mainfrom
feat/fulgora-voronoi-elevation
Aug 5, 2026
Merged

feat(noise): port Factorio's VoronoiNoise primitive, exact at f32 (#27)#160
wormeyman merged 29 commits into
mainfrom
feat/fulgora-voronoi-elevation

Conversation

@wormeyman

Copy link
Copy Markdown
Owner

Tasks 1-6 of the plan in #158, plus one inserted rung. No Fulgora expression code yet - this is the primitive everything else will be transcribed onto. makeVoronoi has no production caller, so nothing user-facing changes.

What landed

Factorio's VoronoiNoise primitive in TypeScript: 4 ops x 4 distance types x 4 jitters, every value exact at f32 (Math.fround, never a tolerance) against ~2,100 values captured from the real 2.1.12 game.

pnpm run verify exits 0 - 199 files / 1718 tests, type-check clean across 364 files.

Method: oracle-first, disassembly where it counts

The two hardest pieces were read out of the binary rather than fitted, which is a stronger result than a statistical match:

  • The per-cell RNG is Thomas Wang's 32-bit mix, all six constants sitting as immediates in VoronoiPoints::VoronoiPoints. Three draws come off one word: +0/+1 are the point's in-cell offsets, +2 is the id.
  • pyramidNoise is the Euclidean distance from the sample to the L1 bisector polyline of two points, minimised over every neighbour except the nearest.

Three errors caught before they could reach a render

None of these would have failed a test suite:

  1. A model validated on a degenerate configuration. Task 2 fitted pyramidNoise at jitter 0 - where every cell is a congruent square and many algorithms coincide. It scored 175/175 there and 0/175 the moment jitter moved, with errors up to half a cell. The disassembly then showed even its correct number had the wrong mechanism: chebyshev's sqrt(9/8) is a hardcoded fmov s16, #0.75 where a true isometry wants 1/sqrt(2), not a clamp artefact.
  2. Correct code shipping with a false comment. It claimed Fulgora didn't need jittered pyramid noise. fulgora_pyramids is exactly that, at jitter 0.6, inside the V1 elevation chain.
  3. A guard everyone believed was load-bearing. pointsSearchRange turned out inert - ring 1 and ring 2 passed identically on all 2,100 values. Task 5 then refuted the geometric argument for why that must be so: it targets the nearest-point loop, but the pyramid answers from its bisector minimum, where a ring-2 point need only be nearly equidistant. Chebyshev at jitter 1 - Fulgora's own fulgora_road_pyramids - shows 177 disagreeing positions in a 4096^2 sweep. The guard is behavioural now.

Two shared primitives touched - please read before merging

  • src/noise/fastApprox.ts (commit 9b49ebb, isolated on purpose). The game rounds log2/exp2 at every step; we accumulated in double and rounded once. Verified instruction-for-instruction against the binary, so this is a bug fix - but it changes shipped Nauvis and Vulcanus output. Multioctave residual improves ~9% and previewAgreement goes 10 -> 9 differing, while regularPatches worst-absolute regresses on 3 of 4 cases (ABS_TOL headroom 0.31 -> 0.18). No downstream test can resolve a 1e-5 shift in either direction, so the green suite is weak evidence here. Easy to revert.
  • src/noise/eval/memoXY.ts recorded coordinates before calling through, so a throwing fn poisoned the slot and the next call returned the previous position's value. Now assigns after; value-identical for functions that return normally. Audited: no existing caller could hit it.

Known follow-ups

  • fastCbrt has the same rounding bug, unfixed - it passes a double 1/3 where Math::powSafe does a single-precision multiply. 2.2% of inputs diverge. Pre-existing; now named rather than unknown.
  • Only 2 of 93 oracle-reading specs compare f32-exact; 38 use tolerances. That is how the fastApprox bug survived a year.
  • Open design call: the binary applies the search range to d1/d2/cell_id too, not just the pyramid. Using pointsSearchRange for all four ops would delete ~55 lines of justification and remove a latent wrong answer for chebyshev facet fields. No Fulgora impact - deliberately left for you.

Record

docs/noise/voronoi-NOTES.md - 541 lines, stating how each claim was measured, including the rejected candidates and the wrong mechanisms that preceded the right answers.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj

wormeyman and others added 28 commits August 5, 2026 04:54
…5 points)

The game's own expression compiler rejects voronoi_pyramid_noise x minkowski3
outright, so the matrix is 15 pairs and not 16. Measured by compiling all
sixteen against the 2.1.12 binary.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
The oracle path floors every sampled position to Factorio's MapPosition fixed
point, so the brief's grid_size/6 step was sampling ~4e-3 tiles away from where
the fixture claimed. Snapping takes the chebyshev/manhattan spot_noise fit from
79/175 to 175/175 with no change to the model.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
Both functions evaluated the whole polynomial in double and rounded to f32 once
at the end. Factorio's Math::log2f/Math::exp2f round after every fadd/fmul/fdiv.
The coefficients were already right; only the rounding was wrong. Constants are
now the exact f32 values of the 2.1.12 arm64 immediates.

This is required for voronoi_spot_noise x minkowski3, the first thing in the repo
compared f32-EXACT rather than by tolerance: 96/175 -> 175/175.

It is NOT a no-op for shipped output. It changes Nauvis, Vulcanus, tree and
resource values by ~1e-5, and no existing test can police that - every downstream
fixture is tolerance-based (multioctave 5e-5, regularPatches ABS_TOL 1.0 /
REL_TOL 1e-2, previewAgreement < 200 px), so a green suite is not evidence the
change was neutral. Measured before -> after instead:

  multioctave       worstNear 2.8853e-5 -> 2.6304e-5; worstFar unchanged 1.1696e-4
  previewAgreement  differing 10 -> 9 of 1047387 compared (budget 200)
  regularPatches    values move on 4093-4105 of 4105 points per case;
                    relative improves (iron/777771 8.849e-3 -> 1.273e-4, 70x)
                    but worst-ABSOLUTE regresses on 3 of 4 cases, cutting
                    ABS_TOL headroom from 0.31 to 0.18

The regression in worst-absolute is accepted: the new rounding is what the game
does. regularPatches' tolerance comment now records these numbers, since it
narrated the old rounding's residual and was left describing a state that no
longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
…ated

Fits all 15 captured series exactly at f32 over 175 positions. Three results a
plausible-looking guess would have got wrong:

- Everything runs in GRID UNITS. Dividing the distance by grid_size at the end is
  algebraically identical and scores 110/175 on minkowski3, whose fastapprox cube
  root amplifies the rounding difference past one ulp.
- minkowski3 goes through the fastapprox log2/exp2 pair, not Math.cbrt (25/175),
  and takes abs() on both terms - the binary's 'bic.2s v0, #0x80' settles the
  docs erratum.
- pyramid_noise differs per distance type, and CHEBYSHEV is the odd one out,
  returning sqrt(9/8) times the edge distance. That comes out of an fsqrt on a
  clamped segment projection, not a scale: multiplying by the f32 nearest
  constant matches only 102/175.

pyramidNoise throws for minkowski3, which the game's own expression compiler
rejects outright, and makeVoronoi throws for jitter != 0 - every formula here is
fitted on a degenerate configuration where all cells are congruent squares, so
reproducing jitter 0 says nothing about jitter > 0.

cellId stays unimplemented and its four series skipped - it needs the R2 hash,
which is Task 3.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
…s exact

The RNG is Thomas Wang's 32-bit integer mix, read out of
NoiseOperations::VoronoiPoints::VoronoiPoints in the 2.1.12 arm64 binary
rather than fitted: all six constants appear there as immediates. The
per-cell word is (seed0 + seed1) ^ wang(cellX) ^ wang(ror16(cellY)), and
the cell draws three values off it - wang(w), wang(w+1) for the point's
in-cell offset and wang(w+2) for the id.

Neither taus88 family fits. A brute-force inversion over all 2^32 taus88
seed words found no additive (cellX, cellY) lattice at all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
cellRandom hardcoded draw +2 (the cell id). Task 4 needs draws +0 and +1,
which are the point's in-cell x/y offsets, and must not transcribe the Wang
mix a second time.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
pointForCell reads the point out of VoronoiPoints' constructor: draws +0/+1 off
the cell's word become jitter*r + (1-jitter)*0.5 in grid units, per axis, all
f32. makeVoronoi's field-wide jitter guard is gone; cellId, spotNoise and
facetNoise are bit-exact on all 36 captured series at jitter 0.6/0.8/1.0.

Two findings the jitter-0 rung could not have surfaced:

- The delta must be rebased on the sample's own cell. Absolute coordinates
  score 3734/4200 with every miss exactly one ulp; the rebased form the binary
  uses scores 4200/4200.
- voronoi_pyramid_noise's jitter-0 formula is the unit-square edge distance and
  is simply wrong once cells are not squares - 0/175 at all nine jitter x
  distance_type combinations, errors up to half a cell. Its guard moves from
  makeVoronoi to pyramidNoise rather than being dropped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
The inversion lattice recovers each cell's point from spot_noise's cone apex,
restricted by the game's own cell_id so a neighbour's point cannot be mistaken
for this one, and the 36 non-pyramid op series assert bit-exact f32 agreement.
The distance-type-independence test - the one Task 6's shared point cache
depends on - finds the SAME lattice position under manhattan and euclidean at
all three jitters.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
Also reattaches cellRandom's docblock, which the draw-index constants had
displaced.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
…se comment

The pyramidNoise guard's comment said solving jittered pyramid noise 'is not
needed by Fulgora'. That is false, and it was false about the one thing the next
implementer must not get wrong. Against factorio-data 2.1.12,
planet-fulgora-map-gen.lua has two jittered pyramid call sites:
fulgora_pyramids (:156, manhattan, jitter 0.6 via :140) feeding the V1 elevation
chain at :214/:221, and fulgora_road_pyramids (:422, chebyshev, jitter 1 via
:406). It is deferred behind a loud guard, not optional.

Also: the pyramidNoise docblock pointed at a makeVoronoi throw that this task
removed; deltaTo now tabulates the exact delta expressions behind each score,
since two absolute variants differ (3734 vs 2921) and 'the absolute form' was
not reproducible; and the spec records that forcing pointOffsetInCell to 0.5
fails 42 of 86 tests, so the exact-value suite is not blind to point placement.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
`voronoi_pyramid_noise` was jitter-0 only, and its jitter-0 formula (the
distance to the nearest edge of the unit square) scored 0 of 175 at every
one of the nine captured jitter x distance_type series. It is now read out
of `runInternal<0..2>` in the 2.1.12 arm64 binary and matches all nine
exactly at f32, plus the three jitter-0 series unchanged.

The algorithm is the minimum, over every neighbouring point except the
nearest, of the EUCLIDEAN distance from the sample to that pair's bisector
under `distance_type`:

- euclidean has a closed form, `dot(midpoint, normalize(b - a))`, because a
  Euclidean bisector is a straight line;
- manhattan and chebyshev go through `computePyramidNoiseManhattan`, which
  builds the L1 bisector POLYLINE (a 45-degree segment plus two rays) and
  takes the nearest of three clamped point-to-segment feet;
- chebyshev first maps into a 45-degree frame, where L-infinity becomes L1.

Three findings that no amount of fitting at jitter 0 would have produced:

- **Chebyshev's `sqrt(9/8)` is one hardcoded immediate.** The 45-degree map
  uses `k = 0.75`; an isometry wants `k = 1/sqrt(2)`, and `0.75 * sqrt(2)`
  is exactly `sqrt(9/8)`. Task 2 got the number right and the mechanism
  wrong (it blamed a clamp biting at a segment endpoint).
- **The search range is per distance type**, from `getPointsSearchRange`:
  chebyshev is pinned at 1, the others are `jitter > {0.5, 0.66, 0.75}`.
  This matters for the pyramid alone - it is a minimum over what the search
  finds, so a wider ring can only lower it. `cellId`/`spotNoise`/`facetNoise`
  keep their fixed ring of 2, which is provably a superset for d1/d2.
- **Manhattan and chebyshev pass the points reflected through the sample.**
  A point reflection about the sample is an isometry fixing it, so this is
  mathematically a no-op - but not an f32 one, so it is reproduced literally.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
The correction landed in 9ce5b6b lived inside the comment block the
jitter guard occupied, which the port removed. It is restated on
`pyramidNoise` itself, now as "this is why the op exists" rather than as
a deferral notice, with the same line citations into
planet-fulgora-map-gen.lua.

Also adds the Task 4b report: the disassembly reading, the derived
formula per distance type, the vacuity-guard result (exactly the nine
jittered series fail under Task 2's formula while the three jitter-0
series still pass), and the verbatim before/after output.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
Review fix. Both this file and the task report claimed the game's
per-distance-type search range mattered to `pyramidNoise` - "load-bearing
for the pyramid and only for the pyramid", "`pyramidNoise` may NOT use
[SEARCH_RING]". Both read as measured and neither was. Probed: forcing
`pointsSearchRange` to 2 for all four types passes 95/95, and forcing it
to 1 for all four types also passes 95/95, so no branch of it is
exercised in either direction across all 1575 jittered and 525 jitter-0
values. The harness is live - the same probe on CHEBYSHEV_FRAME fails 4.

It stays, because it is what the binary does and it was free to
transcribe, and because a wider ring can only lower a min while at
jitter <= 1 every point sits inside its own cell so a ring-2 bisector is
never nearest. But that is geometry, not something the suite endorsed,
and the next task must not inherit the stronger claim.

Also: the header's series count for the points fixture is 45 now, not 36
(9 pyramid series joined it); `bisectorDistanceL1` no longer evaluates
`sq(r)`/`sq(w)` twice each on a render hot path (95/95 unchanged); and
the `Math.min`/`Math.max` stand-ins for `fcsel mi`/`fcsel gt` carry a
note that they diverge only on NaN and +0/-0, neither reachable here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
…ches

`pointsSearchRange` was read out of the binary in Task 4b and independently
re-derived by its reviewer, but nothing tested it: forcing it to 2 for all four
distance types passed 95/95, and forcing it to 1 also passed 95/95. All 2100
committed voronoi values were indifferent to it, in both directions - the class
of guard this repo keeps finding and fixing.

The geometric argument for why that had to be so was wrong. "One ring is
provably enough at jitter <= 1" holds only for chebyshev, where the own cell's
point has max(|dx|,|dy|) < 1 and every ring-2 point exceeds 1 - which is exactly
why the jump table pins chebyshev at 1. For the other three the own-cell bound
is 2, sqrt(2) and 2^(1/3), so a ring-2 point genuinely can win.

Only voronoi_pyramid_noise can discriminate, and not through the argmin: its
second loop minimises the distance to each pair's BISECTOR, which for euclidean
is (|f|^2 - |n|^2) / (2|f - n|) and is small whenever a far point is nearly
EQUIDISTANT rather than nearer. So a ring-2 point does not have to win anything.

oracle-voronoi-search-range.seed123456.json captures the game at 37 positions
found by sweeping the port with the ring forced to each value. Both branches are
pinned in both directions: chebyshev at jitter 1 reads the game's ring-1 answer
(Fulgora's fulgora_road_pyramids configuration), manhattan and euclidean at
jitter 1 read its ring-2 answer, and manhattan 0.7 / euclidean 0.9 are the
lowest jitters found to discriminate at all, bounding those thresholds from
above.

What the game will NOT say is where the thresholds are: a ring-1/ring-2
disagreement needs high jitter, and 4096x4096-tile sweeps at manhattan 0.5 and
euclidean f32(0.66) found zero. The exact 0.5 / f32(0.66) / 0.75 rest on the
disassembly plus a table test that is labelled in the spec as the weaker
transcription check it is.

A sweep of d1/d2 under both rings also cleared the fixed SEARCH_RING of 2 that
spot/facet/cell_id use - d1 and cell_id never differ, and the manhattan d2
differences at jitter 0.8 and 1 land where the game's own range is 2 as well.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
A render sweep revisits the same grid cells tens of thousands of times and
each sample touches a 25-cell block, so the six Wang mixes per cell were
being redone per pixel. Three layers, all byte-exact by construction
(values are returned by identity, never recomputed):

- a Map<number, {x,y}> of in-cell point offsets in makeVoronoi's closure,
  keyed by the packed cell index;
- a one-entry cache over the d1/d2/argmin search, so cellId + spotNoise +
  facetNoise at one pixel run it once rather than three times;
- memoXY on each of the four returned ops.

pyramidNoise's minkowski3 guard is hoisted OUT of the memo: memoXY records
the coordinates before calling through, so a wrapped throwing function
would leave the slot claiming a value it never produced.

All 120 existing exact-value tests pass unchanged, which is the proof.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
…greement

Five caching tests plus a structural guard on `searchRangeOverride`.

The brief's second test asserted that two fields sharing seed/grid/jitter
agree on `cellId` across distance types. **That is false and the test fails
at 2 of its own 50 positions.** Point PLACEMENT is distance-type-blind
(`VoronoiPoints`' constructor never reads the type byte at +0x26), but which
point is NEAREST is chosen under the metric. Measured over a 400x400 grid,
stride 3.25, origin (-650,-650), gridSize 175, jitter 0.6: manhattan vs
euclidean disagree on 10933/160000 = 6.83%; vs chebyshev 13.07%; euclidean
vs minkowski3 2.66%.

Replaced with the invariant that IS a consequence of a shared point set:
`spotNoise` obeys chebyshev <= euclidean <= manhattan, whatever point wins
in each, because min preserves |v|_inf <= |v|_2 <= |v|_1. Non-vacuity is
measured, not asserted - changing one field's seed1 to 999 violates it at
30036/40000 positions. minkowski3 is excluded on purpose: its fastapprox
cube root breaks the exact-arithmetic ordering at 1927/40000 near-ties,
which is the port being faithful, not a defect.

`searchRangeOverride`'s "nothing that renders a map may set this" was
documentation only; it is now a spec that walks src/ and allows the
declaration alone. Confirmed to discriminate by planting an offender.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
… add the Fulgora table

Three corrections to comments that read as measurements but were not usable.

1. The d1/d2 sweep numbers were not reproducible: "a 1024x1024-tile sweep"
   and "58 of 262144" only reconcile at an unstated stride of 2. Re-run with
   the window written down - seed0 123456, seed1 0, gridSize 175, origin
   (0,0), 512x512 tiles at stride 1, ring 2 vs ring 1 compared value for
   value across all 12 distance_type x jitter configurations. Result: d1 and
   cell_id differ in NONE (0/262144 twelve times); d2 differs in exactly one,
   manhattan at jitter 1, at 496/262144.

2. The residual-risk note named chebyshev at jitter 1 as "the case with the
   least margin". Wrong case: the same comment already proves chebyshev is
   ring-insensitive at every jitter <= 1. So is euclidean at 0.6. The
   unproved class is d2/facet where the game's range is 1.

3. Which nothing ships. The complete Fulgora call-site table (seven sites,
   each verified at its line in the pinned 2.1.12 lua - the earlier informal
   list omitted fulgora_road_cells and both fulgora_structure_*) shows the
   one d2 site is minkowski3 at jitter 0.8, whose game range is 2 - the fixed
   ring, so it agrees by construction. Residual is nil, not "measured small".

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
…or each claim

Every section states HOW it was measured, per the repo's rule that a stated
cause with no stated measurement is a hypothesis. Covers the grid-unit
normalisation (only minkowski3 discriminates, 110/175 vs 175/175); the Wang
hash read as immediates out of VoronoiPoints' constructor, with the three
rejected candidates and the evidence against each (taus88 by exhaustive 2^32
inversion; basis-noise seeding by a shared-word discrimination probe;
F(cx)^F(cy) by a prediction the fixture denies); the offset formula with
jitter narrowed to f32 first (2939/4200 if carried as a double); the delta
rebasing, whose best absolute form misses by exactly one ulp on 466 of 4200
while cell_id scores 100% under it; the pyramid's bisector minimum and the
reflection that is not an f32 no-op; the search range and the FALSE "a wider
ring can only lower a min" argument; and the 1/256 MapPosition snapping trap
that cost Task 2 a 79/175.

Two lessons are called out as the load-bearing ones. jitter 0 is degenerate -
Task 2's pyramid formula matched 175/175 there and 0/175 everywhere else. And
chebyshev's sqrt(9/8) is one hardcoded `fmov s16, #0.75`: Task 2 got the
number right with the mechanism wrong, which is the single best argument in
this effort for reading the binary over fitting a curve.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
…s non-vacuous

Review found three of the five caching tests vacuous - they pass with ALL
THREE cache layers stripped - while the describe block claimed they prove
the caches are wired. That is the repo's own recurring failure mode sitting
inside the file that documents the fix for it.

- New "observes the caches doing work": counts CALLS, not values, by spying
  on Math.sqrt (a live lookup inside distanceOf) and Map.prototype.set.
  Every arm confirmed to discriminate by removing its own layer: dropping
  memoXY takes the 2nd pyramidNoise 0 -> 17; dropping the search cache takes
  facet/cellId 0 -> 25 each; dropping the point Map takes Map.set 25 -> 0.
  Each layer moves exactly one column.
- The block now says plainly that the other four are determinism checks and
  that the 116 exact-value tests are the only correctness proof. Also
  confirmed the other way: with all layers stripped, all 116 still pass.

memoXY.ts now assigns lastX/lastY AFTER fn returns. Value-identical for any
function that returns normally, and it retires the hazard class instead of
documenting it at one call site. test/memoXY.spec.ts pins it as a
dirty/clean pair (the old ordering returns a stale 11 where the fixed one
throws) - the bug is invisible on the first call, so a single assertion
could not have caught it.

The 4096^2 figure contradicted itself, 113 in three places and 177 in one,
and neither carried a window, stride or seed. Both retired and re-measured:
553 / 16777216 at origin (0,0), stride 1 tile, seed0 123456 / seed1 0 /
gridSize 175. The same window reads 145 at stride 2 and 39 at stride 4 -
one ~3.3e-5 density through three grids - which is exactly why a count
without its stride says nothing.

Also: 120 -> 116 (the old count folded in fixtureProvenance's 4), and all
Fulgora line citations normalised to the `name =` line.

Ran: full app suite, 199 files / 1718 passed (1 file, 3 tests skipped).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
Both `src/noise/voronoiNoise.ts` and `docs/noise/voronoi-NOTES.md` cited
`fmov s16, #0.75000000` at `0x101772864`. That address holds
`fmul s26, s26, s16` - the first USE of the register, not the definition. The
`fmov` is at `0x101772414`, 0x450 earlier in the same
`VoronoiNoise::runInternal<DistanceType 0>`.

Verified by disassembling both windows out of the arm64-thinned 2.1.12 binary.
The section that carries this is the one arguing the binary is cheap to read, so
a wrong address in it is worse than a wrong address anywhere else in the file.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
…ured on

`docs/noise/voronoi-NOTES.md` and `test/voronoiNoise.spec.ts` both reported the
metric-ordering non-vacuity probe as "the same sweep" as the cell_id
disagreement table. It is not: that table's window is 400x400 at stride 3.25
(160000 positions), and re-running the two probes there gives 120066 and 7868,
not 30036 and 1927. The window that reproduces the published numbers is 200x200
at stride 6.5, same origin (-650,-650) and same field parameters.

Re-measured both windows before editing. This is the identical
unreproducible-count problem section 6 of the same file corrects, two sections
away.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
… caching

Four counts in `docs/noise/voronoi-NOTES.md` and `src/noise/voronoiNoise.ts` were
wrong, all of them derived by arithmetic on remembered totals rather than counted:

- "126 tests" -> 123 (101 + 22, no skips).
- "116 pre-existing exact-value tests in test/voronoiNoise.spec.ts" -> 117, and
  they are across BOTH spec files (95 + 22). The source comment showed its work
  correcting an earlier 120, which made it read as verified.
- "three of the five caching tests are vacuous" -> five of the SIX. The block
  holds six `it`s, and stripping all three cache layers leaves the two files at
  `1 failed | 122 passed` - only "observes the caches doing work" fails. The old
  sentence also contradicted its own next line, which already said only that one
  test discriminates.

Counted with `vp test -t "makeVoronoi caching"` (6 passed | 95 skipped) and by
re-running both files with the layers stripped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
None of these change behaviour; all five were checked against the thing they
describe rather than transcribed.

- memoXY.spec.ts: the dirty arm returns 11, not 10 - the value from (1, 1), which
  is exactly why it is a stale-value bug. The file's own assertion said 11.
- voronoiSearchRange.spec.ts: the fixture holds 37 positions, not 40 (8/11/6/11/1
  across its five series).
- voronoiNoise.spec.ts repeat-throw test: it does NOT catch a memoXY regression.
  Reverting memoXY to record-coordinates-first leaves all 101 tests in that file
  green; only test/memoXY.spec.ts fails. It guards the hoist, and that is enough
  - the two guards are disjoint, not redundant.
- voronoiNoise.spec.ts apexOf: the cellIds owner filter is currently INERT. It
  discards 27.3-42.7% of the 4096 lattice positions, and filtered argmin ==
  unfiltered argmin in all 6 series. Kept (the argument is about spot_noise's
  definition, not this capture) but no longer described as load-bearing.
- regularPatches.spec.ts: there are two f32-exact guards now, not one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
…s that own it

The per-operation rounding fix landed in `src/noise/fastApprox.ts` with a full
explanation at the code, but neither NOTES file that owns the fastapprox story
heard about it. Both are living documents, so they are updated rather than left
to drift.

- `multioctave-noise-NOTES.md` still listed the OLD decimal constants read out of
  2.1.11. Replaced with the exact f32 values of the 2.1.12 immediates, plus a
  dated paragraph on what changed and why. Its `norm uses fastapprox pow` results
  row (6.7e-5 -> 2.9e-6) was measured under the double-accumulating
  implementation and is now marked as predating the change - it still supports
  the claim it was made for, it is just not a current residual.
- `random-penalty-NOTES.md` owns the fastapprox-cbrt extraction and carried no
  note at all. Added a dated one: `fastCbrt` moves ~1e-5, the "< 0.7 units
  everywhere" result no longer holds, ABS_TOL is now the binding tolerance with
  margin 0.18, and the outstanding double-`1/3` in `fastCbrt` is named as a
  follow-up.
- `fastApprox.ts` header said `Math::log2f`. There is no such symbol: `nm` +
  `c++filt` give `Math::log2(float)` and `Math::exp2f(float)`, so only exp2
  carries the `f`. The pre-9b49ebb header was correct; both occurrences fixed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
31 other files under `src/` cite their `docs/noise/*-NOTES.md`; this one did not.
The only pointer to that 541-line record was from
`docs/superpowers/specs/2026-08-04-fulgora-elevation-preview-design.md`, and
CLAUDE.md says specs are point-in-time records rather than current state - so the
record was reachable only from a document you are told not to trust as current.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
@wormeyman

Copy link
Copy Markdown
Owner Author

Follow-ups from the PR description are now filed, each with its evidence re-measured rather than carried over from this description:

One thing that came out of filing them, worth recording here because it is a negative result that would otherwise get re-derived: Math::powSafe has an integral-exponent fast path (exponentiation by squaring, 0x102955ab0) that skips fastapprox entirely, and fastPow's two other call sites pass an integer octaves. That looked like a second latent bug. It is not - swapping the multioctave norm to f32 squaring makes the oracle error 20x worse (worstNear 2.630e-5 -> 5.332e-4, failing both gates), so the noise machine's normalisation genuinely does not route through powSafe. Details in #163.

The fourth item in the description - applying pointsSearchRange to all four ops - is not an issue; Eric has taken that call and it is being made on this branch.

… just the pyramid

The binary reads getPointsSearchRange() once at the top of runInternal and uses
it for both the generated point region and the [-range, +range] loop bounds.
This port applied it to pyramidNoise only; d1/d2/cell_id walked a hardcoded 5x5
block. One binding is now shared by all four ops.

Measured before the change, over a 1400x1400-tile window at stride 1 tile
(1960000 samples, seed0 123456 / seed1 0 / gridSize 175): spot, facet and
cell_id are identical at ring 1 and ring 2 in all six configurations whose game
range is 1. So this moves no value - all 2100 committed oracle values pass
unchanged - and is a faithfulness change plus a 1.7x-2.3x speedup at Fulgora's
two range-1 sites (fulgora_spots 94->55ms, fulgora_road_cells 108->46ms over a
700x700 sweep).

The stated motivation is REFUTED and recorded as such: this does not remove "a
latent wrong answer for chebyshev facet fields". d2 genuinely can see the ring -
manhattan at jitter 1 differs at 828 of those 1960000 - but the game's range
there is 2, so the fixed ring was already right at that configuration, and
chebyshev facet agrees everywhere.

Found while writing the test: searchRangeOverride only ever reached
pyramidNoise, so planting a ring on a facetNoise field silently did nothing and
the first sweep through the hook returned a clean-looking 0 differences while
measuring nothing. It is honoured at a single site now, and the new
"searchRangeOverride reaches facetNoise" test fails on the pre-change code.

The cache-observation test in voronoiNoise.spec.ts now reads 9 rather than 25;
that count is the only place in the suite where this switch is directly
observable, since every committed value is identical either way.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01DutxVPSqj12PtwNE46RtSs
@wormeyman

Copy link
Copy Markdown
Owner Author

Pushed 7931523 - the fourth open item from the description, now taken. Read this before the diff; the change is right but not for the reason it was proposed.

All four ops use getPointsSearchRange now; the three point ops previously walked a hardcoded 5x5. pnpm run verify exits 0 (199 files / 1721 tests) and all six CI checks pass.

The motivating prediction was refuted. It does not "remove a latent wrong answer for chebyshev facet fields". Over a 1400x1400-tile window at stride 1 (1,960,000 samples, seed0 123456 / seed1 0 / gridSize 175), spot, facet and cell_id are identical at ring 1 and ring 2 in all six configurations whose game range is 1. The reasoning behind the prediction was sound - d2 only needs a ring-2 point to beat the second best, so the old argmin bound never covered it - and d2 demonstrably can see the ring (manhattan at jitter 1 differs at 828 of those positions). But the game's range there is 2, so the fixed ring was already right at that configuration.

So it ships as faithfulness + perf: 9 cells instead of 25 at the two Fulgora range-1 sites, measured 1.7x (fulgora_spots 94->55ms) and 2.3x (fulgora_road_cells 108->46ms) over a 700x700 sweep. Every committed oracle value is unchanged.

One thing found while writing the test, which is the part I'd want a reviewer to look at. searchRangeOverride is documented as the hook that plants the wrong ring - it only ever reached pyramidNoise. My first sweep ran through it and returned 0 differences across all four distance types, which read as a clean "the ring is inert" result. It was measuring nothing: both arms ran identical code. Wiring the lever into search() turned the same sweep into 828 differences. The hook is honoured at a single site now, and searchRangeOverride reaches facetNoise, not only pyramidNoise fails on the pre-change code.

Because the change is value-inert, the only place in the suite where it is observable is the Math.sqrt count in voronoiNoise.spec.ts's cache test, which goes 25 -> 9. Without that a revert would be silent, so please don't "fix" that number back.

Task 7 (fulgoraShared.ts) is not started - holding as you asked.

@wormeyman
wormeyman merged commit 0e25835 into main Aug 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant