feat(noise): port Factorio's VoronoiNoise primitive, exact at f32 (#27) - #160
Conversation
… far-field probes Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01QZvNS2H4cbaZk46ybA7hbj
…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
… 256) 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
|
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: The fourth item in the description - applying |
… 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
|
Pushed All four ops use 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, So it ships as faithfulness + perf: 9 cells instead of 25 at the two Fulgora range-1 sites, measured 1.7x ( One thing found while writing the test, which is the part I'd want a reviewer to look at. Because the change is value-inert, the only place in the suite where it is observable is the Task 7 ( |
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.
makeVoronoihas no production caller, so nothing user-facing changes.What landed
Factorio's
VoronoiNoiseprimitive 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 verifyexits 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:
VoronoiPoints::VoronoiPoints. Three draws come off one word:+0/+1are the point's in-cell offsets,+2is the id.pyramidNoiseis 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:
pyramidNoiseat 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'ssqrt(9/8)is a hardcodedfmov s16, #0.75where a true isometry wants1/sqrt(2), not a clamp artefact.fulgora_pyramidsis exactly that, at jitter 0.6, inside the V1 elevation chain.pointsSearchRangeturned 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 ownfulgora_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(commit9b49ebb, isolated on purpose). The game roundslog2/exp2at 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% andpreviewAgreementgoes 10 -> 9 differing, whileregularPatchesworst-absolute regresses on 3 of 4 cases (ABS_TOLheadroom 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.tsrecorded coordinates before calling through, so a throwingfnpoisoned 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
fastCbrthas the same rounding bug, unfixed - it passes a double1/3whereMath::powSafedoes a single-precision multiply. 2.2% of inputs diverge. Pre-existing; now named rather than unknown.fastApproxbug survived a year.d1/d2/cell_idtoo, not just the pyramid. UsingpointsSearchRangefor 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