Skip to content

Commit df3e39e

Browse files
wormeymanclaude
andauthored
Narrow basis_noise's output_scale the way the game does, 196/196 (#269) (#288)
`basisNoiseExpr` returned `output_scale * basis` as an un-narrowed f64. The game evaluates `f32(f32(output_scale) * basis)`. Graded against the game at 196 positions and five output scales using the fixture captured in #287: f32(f32(output_scale) * basis) 196/196 at all five scales <- the game f32(output_scale * basis) 196, 110, 151, 196, 196 <- #269's proposal output_scale * basis (shipped) 196, 28, 6, 96, 1 f32(output_scale) * basis 196, 0, 0, 0, 1 Every count is an exact f64 equality, never a bound. Both sides produce f32 values, so a tolerance would pass for all four models at once and measure nothing (#162). **Narrowing the product is necessary and NOT sufficient, which is the half the issue does not say.** The `output_scale` CONSTANT is held at f32 as well - the same shape as #273 - so this is both cases of the two-case rule in `src/noise/eval/f32.ts` at a single call site. The two forms coincide only where `output_scale` is already f32-exact, which is why the second row above is complete at 1 and 125 and short at 0.6, 0.51 and 0.75. The assertion in `test/basisOutputScale.spec.ts` that froze `[196, 28, 6, 96, 1]` was written to go red when this landed, and it did. It is now all-196 rather than loosened. ## Re-scored, because a green gate cannot see this change This alters shipped behaviour in five expression files that each pass their own oracle spec, and #273 measured that this class of change moves counts in BOTH directions. So every affected field was re-scored by exact f32 matches, before and after, by reverting only `basisNoiseExpr` on this same tree: vulcanus mountain_plasma 7 -> 11 of 38 vulcanus elev 114 -> 116 of 434 vulcanus elevation 114 -> 116 of 434 nauvis elevation_lakes 13 -> 13 of 17 (control) nauvis elevation_nauvis 3 -> 3 of 17 (control) cliffiness gate seed=123456 1024 -> 1024 (control) cliffiness gate seed=777771 1024 -> 1024 (control) Nothing regressed. `test/basisOutputScaleCallers.spec.ts` freezes those counts as a live spec rather than leaving them in a commit message, for the reason `fixtures.rs` carries #273's planted fix as a test: a measurement nobody runs goes stale. Reverting `basisNoiseExpr` takes it red at 7 and 114 - watched, not assumed - while its control test stays green. Which sites the fix can reach is decided by the output scale alone. A power-of-two scale is a pure exponent shift and can never leave the f32 grid, so the eleven `plasma` calls the crack layer makes at 1, 0.5 and 0.25 are blind by construction and did not move. `cliff_fields` (0.51), `nauvis_shared` (0.6), `elevation_lakes` (1.5), `vulcanus_elevation` (250, 150) and the `plasma` magnitudes routed into it (125/625, 0.15/0.75) are the exposed ones. ## A measured instance of #256 The full TypeScript suite passed with ZERO failures under this change. Every oracle spec covering these five callers asserts a combined abs/rel bound wide enough to swallow the whole difference, so `pnpm run verify` going green was not evidence of anything. Only the two frozen-count specs could see it. That is recorded in CLAUDE.md next to the three port findings, because the next person to change a shared op needs to know the gate will not catch them. ## Rust `basis_noise_expr` now returns `f64::from((output_scale as f32) * basis)`. An f32 multiply IS `f32(f32(os) * basis)`: the exact product of two f32s fits in an f64 mantissa, so there is no double rounding and it matches `Math.fround(Math.fround(os) * basis)` bit for bit. Its docblock argued at length that NOT narrowing was deliberate, because no fixture could grade the difference and a unilateral change would read as a port bug in tier 2. That reasoning was sound and is now spent: #287 captured the fixture, and both ports change in this commit, so tier 2 stays honest. The docblock records that rather than quietly dropping it. The `the_output_scale_multiplies_the_result_once` test asserted the old reading and is replaced by `the_output_scale_and_the_product_are_both_narrowed`, which keeps the original's discriminating scan and adds a second counter for points that separate narrowing the product alone from also holding the constant at f32 - so the scan can see the half #269 omits. A new `a_power_of_two_output_scale_is_blind_to_the_narrowing` asserts the immunity rule directly. 137 Rust tests, up from 136. `engine.wasm` rebuilt: 84,160 bytes, 4031d1d7. No POISONED_TESTS entry names the renamed test, and no op was added, so none is owed. Full gate green at VERIFY_RC=0, including the committed-wasm byte compare. Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1b01dee commit df3e39e

7 files changed

Lines changed: 350 additions & 60 deletions

File tree

CLAUDE.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,11 +1432,32 @@ shipped call sites.
14321432
port.** All were behaviour changes to shipped fields that passed their own
14331433
fixtures, so each got an issue instead. The port reproduces the TypeScript
14341434
exactly in every case - a unilateral "fix" on the Rust side would read as a port
1435-
bug in tier 2, which is the whole point of having tier 2. **Two have since
1435+
bug in tier 2, which is the whole point of having tier 2. **All three have since
14361436
landed as their own changes**, which is the intended path, not an exception:
14371437

1438-
- **#269 - STILL OPEN.** `basisNoiseExpr` returns an un-narrowed f64 product
1439-
where the game narrows to f32, and none of its five callers narrow either.
1438+
- **#269 - LANDED.** `basisNoiseExpr` returned an un-narrowed f64 product where
1439+
the game evaluates `f32(f32(output_scale) * basis)`, and none of its five
1440+
callers narrowed either. Settled against the game at 196 positions and five
1441+
output scales (the fixture is #287's): the shipped form scored
1442+
`[196, 28, 6, 96, 1]` and the game's form scores **196 of 196 at every scale**.
1443+
**Narrowing the product is necessary and NOT sufficient** - the `output_scale`
1444+
CONSTANT is held at f32 as well, the same shape as #273, and that is the half
1445+
the issue itself does not say.
1446+
1447+
Re-scored on every field that reads it, because a green gate proves nothing
1448+
here (see below): `mountain_plasma` **7 -> 11 of 38**, Vulcanus `elev` and
1449+
`elevation` **114 -> 116 of 434**, and `elevation_lakes` (13/17),
1450+
`elevation_nauvis` (3/17) and both `cliffiness` gates (1024/1024) unmoved.
1451+
Nothing regressed - unlike #273, which moved counts in both directions.
1452+
1453+
**A power-of-two `output_scale` is immune** and cannot grade any of this:
1454+
multiplying an f32 by one is a pure exponent shift. Which sites the fix can
1455+
reach is decided by the output scale alone - `cliff_fields` (0.51),
1456+
`nauvis_shared` (0.6), `elevation_lakes` (1.5), `vulcanus_elevation`
1457+
(250, 150) and the `plasma` magnitudes routed into it (125/625, 0.15/0.75)
1458+
are all exposed, while the eleven `plasma` sites the crack layer calls at
1459+
1, 0.5 and 0.25 are blind by construction and did not move.
1460+
14401461
- **#270 - FIXED.** The wasm libm question above. Closed by deleting the
14411462
un-narrowed `slider_rescale` and moving all five callers onto the
14421463
per-operation form the oracle says the game uses.
@@ -1452,6 +1473,17 @@ landed as their own changes**, which is the intended path, not an exception:
14521473
The shape to copy: find it while porting, reproduce it faithfully so tier 2 stays
14531474
honest, open an issue, and fix it in a change graded on its own.
14541475

1476+
**A green `pnpm run verify` cannot see a change of this class - measured, not
1477+
assumed (#256).** When #269's fix landed, the full TypeScript suite passed with
1478+
**zero failures** even though the model under seven call sites had changed,
1479+
because the oracle specs that cover those callers assert combined abs/rel bounds
1480+
rather than exact f32 matches. The bounds are wide enough to swallow the whole
1481+
difference. The only spec that noticed was `test/basisOutputScale.spec.ts`,
1482+
which freezes exact counts on purpose. So when you change an op that shipped
1483+
fields read, **re-score those fields exactly before and after** - the gate going
1484+
green is not evidence, and #162 is the standing record of a tolerance hiding a
1485+
real bug for a year.
1486+
14551487
**`f64::max` is NOT `Math.max`, and only a raw-bits fold can see the
14561488
difference** (found 2026-08-19, #224). They differ two ways: on NaN, where
14571489
`f64::max` returns the non-NaN operand and `Math.max` propagates - and on

crates/fmw-noise/src/eval/primitives.rs

Lines changed: 93 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,42 @@ pub struct BasisExprParams {
3535
/// there, and narrowing it here would evaluate a DIFFERENT point. This is the
3636
/// same reading `fixtures::score` records for the raw kernel.
3737
///
38-
/// ## It returns f64, and the missing narrowing is DELIBERATE (#269)
38+
/// ## The output scale is narrowed twice, and both are needed (#269)
3939
///
40-
/// The game's noise machine evaluates `output_scale * basis_noise(...)` as one
41-
/// f32 operation. The TypeScript does not narrow it - it returns the plain f64
42-
/// product - and neither do any of its five callers: `nauvis_shared` writes
43-
/// `0.65 + basisNoiseExpr(...)`, `elevation_lakes` writes
44-
/// `basisNoiseExpr(...) + sld/4 - 4`, both in f64.
40+
/// The game evaluates this as `f32(f32(output_scale) * basis)`. That is BOTH
41+
/// cases of the two-case rule at one call site, which is why neither half alone
42+
/// reaches the game:
4543
///
46-
/// So this port returns f64 too. **Narrowing here would be a silent behaviour
47-
/// change to five shipped expression files**, every one of them currently
48-
/// passing its own oracle fixture, and this phase has no fixture that can grade
49-
/// the difference. The two-case rule in [`super`] says isolate the term and
50-
/// measure before fixing it; #269 tracks doing exactly that against those five
51-
/// fixtures. Until then the port carries the same approximation the TypeScript
52-
/// carries, on purpose, because tier 2 compares the two ports and a unilateral
53-
/// "fix" here would read as a port bug.
44+
/// - **The CONSTANT.** `output_scale` is a program literal the engine holds at
45+
/// f32. The f64 `0.6` is 0.59999999999999997780 and no amount of rounding the
46+
/// product recovers the difference.
47+
/// - **The PRODUCT.** `basis_noise` returns an f32 and the multiply is its own
48+
/// f32 operation, so its result is f32 before anything downstream reads it.
49+
///
50+
/// Graded against the game at 196 positions and five output scales in
51+
/// `test/basisOutputScale.spec.ts`. Exact equality, never a bound (#162):
52+
///
53+
/// ```text
54+
/// f32(f32(output_scale) * basis) 196/196 at all five scales <- the game
55+
/// f32(output_scale * basis) 196, 110, 151, 196, 196 <- #269's proposal
56+
/// output_scale * basis 196, 28, 6, 96, 1 <- what shipped
57+
/// f32(output_scale) * basis 196, 0, 0, 0, 1
58+
/// ```
59+
///
60+
/// **A power-of-two `output_scale` is immune** and cannot grade this: multiplying
61+
/// an f32 by one is a pure exponent shift, so the product can never leave the
62+
/// f32 grid. That is why the older `oracle-basis` fixture, captured at
63+
/// `output_scale = 1`, could not answer the question and a new capture had to.
64+
///
65+
/// An earlier revision of this comment argued at length that NOT narrowing was
66+
/// deliberate, because no fixture could grade the difference and a unilateral
67+
/// change here would read as a port bug against the TypeScript. That reasoning
68+
/// was sound and it is now spent: #287 captured the discriminating fixture, the
69+
/// TypeScript changed in the same commit as this, and the two ports still agree.
70+
///
71+
/// The `input_scale` product is deliberately NOT narrowed. It decides which
72+
/// point gets sampled rather than what the product rounds to. Whether the game
73+
/// holds `input_scale` at f32 too is a separate, unmeasured question on #269.
5474
#[must_use]
5575
pub fn basis_noise_expr(
5676
x: f64,
@@ -63,7 +83,10 @@ pub fn basis_noise_expr(
6383
y * params.input_scale,
6484
tables,
6585
);
66-
params.output_scale * f64::from(v)
86+
// f32 * f32 in one operation IS `f32(f32(os) * basis)`: the exact product of
87+
// two f32s fits in an f64 mantissa, so there is no double rounding to dodge
88+
// and this matches `Math.fround(Math.fround(os) * basis)` bit for bit.
89+
f64::from((params.output_scale as f32) * v)
6790
}
6891

6992
#[cfg(test)]
@@ -111,49 +134,77 @@ mod tests {
111134
assert_ne!(got, wrong);
112135
}
113136

114-
/// `output_scale` multiplies the RESULT, in f64, with NO narrowing - see
115-
/// the docblock and #269.
137+
/// `output_scale` is narrowed to f32 and so is the product: the game
138+
/// evaluates `f32(f32(output_scale) * basis)` (#269).
116139
///
117-
/// The second half is what makes this test say something: it picks an
118-
/// output scale whose product is NOT f32-representable, so a port that
119-
/// narrowed would return a different number. Without it the assertion would
120-
/// hold for either reading.
140+
/// The scans are what make this test say something. A hand-picked point
141+
/// proves nothing here - at (3.25, -7.5) with a scale of 0.1 the f64 product
142+
/// happens to land exactly on an f32, so it cannot tell the readings apart.
143+
/// That was found by an assertion failing, not by reading the code, which is
144+
/// why both halves scan and then assert they FOUND discriminating points.
121145
#[test]
122-
fn the_output_scale_multiplies_the_result_once() {
146+
fn the_output_scale_and_the_product_are_both_narrowed() {
123147
let tables = tables_from_seed(123_456, 123);
124148
let mut p = params();
149+
150+
// Half one: an f32-exact scale, so only the PRODUCT narrowing can show.
125151
p.output_scale = 7.5;
126-
let raw = basis_noise((3.25) / 32.0, (-7.5) / 32.0, &tables);
152+
let raw = basis_noise(3.25 / 32.0, -7.5 / 32.0, &tables);
127153
assert_eq!(
128154
basis_noise_expr(3.25, -7.5, &p, &tables),
129-
7.5 * f64::from(raw)
155+
f64::from(7.5_f32 * raw)
130156
);
131157

132-
// And the discriminating half. A single hand-picked point is not
133-
// enough: at (3.25, -7.5) with a scale of 0.1 the f64 product happens
134-
// to land exactly on an f32, so it cannot tell the two readings apart.
135-
// That was found by the assertion below failing, not by reading the
136-
// code, which is why this scans instead.
158+
// Half two: a scale that is NOT f32-exact, so the CONSTANT narrowing
159+
// shows too. `vs_unnarrowed` counts points that separate the shipped
160+
// model from this one; `vs_product_only` counts points that separate
161+
// narrowing the product ALONE from narrowing the constant as well -
162+
// which is the half #269 itself does not say.
137163
p.output_scale = 1.0 / 3.0;
138-
let mut discriminating = 0usize;
164+
let mut vs_unnarrowed = 0usize;
165+
let mut vs_product_only = 0usize;
139166
for k in 0..64 {
140167
let (x, y) = (f64::from(k) * 3.25 - 40.0, f64::from(k) * -1.75 + 11.0);
141-
let want = p.output_scale
142-
* f64::from(basis_noise(
143-
(x + p.offset_x) * p.input_scale,
144-
y * p.input_scale,
145-
&tables,
146-
));
168+
let basis = basis_noise((x + p.offset_x) * p.input_scale, y * p.input_scale, &tables);
169+
let want = f64::from((p.output_scale as f32) * basis);
147170
assert_eq!(basis_noise_expr(x, y, &p, &tables), want);
148-
if want != f64::from(want as f32) {
149-
discriminating += 1;
171+
172+
let unnarrowed = p.output_scale * f64::from(basis);
173+
if want != unnarrowed {
174+
vs_unnarrowed += 1;
175+
}
176+
if want != f64::from((p.output_scale * f64::from(basis)) as f32) {
177+
vs_product_only += 1;
150178
}
151179
}
152180
assert!(
153-
discriminating > 32,
154-
"only {discriminating} of 64 points can tell the un-narrowed f64 \
155-
product from its f32 narrowing, so the assertions above mostly \
156-
hold for either reading"
181+
vs_unnarrowed > 32,
182+
"only {vs_unnarrowed} of 64 points separate this from the un-narrowed f64 product, so the assertions above mostly hold for either reading"
157183
);
184+
assert!(
185+
vs_product_only > 0,
186+
"no point separates narrowing the product alone from also holding output_scale at f32, so this scan cannot see the second half of #269"
187+
);
188+
}
189+
190+
/// A power-of-two `output_scale` cannot grade any of this: multiplying an
191+
/// f32 by one is a pure exponent shift, so every candidate model coincides.
192+
/// This is why the older `oracle-basis` fixture, captured at
193+
/// `output_scale = 1`, could not answer #269.
194+
#[test]
195+
fn a_power_of_two_output_scale_is_blind_to_the_narrowing() {
196+
let tables = tables_from_seed(123_456, 123);
197+
let mut p = params();
198+
for os in [1.0, 0.5, 0.25, 2.0, 4.0, 64.0] {
199+
p.output_scale = os;
200+
for k in 0..64 {
201+
let (x, y) = (f64::from(k) * 3.25 - 40.0, f64::from(k) * -1.75 + 11.0);
202+
let basis =
203+
basis_noise((x + p.offset_x) * p.input_scale, y * p.input_scale, &tables);
204+
// The un-narrowed product and the fully narrowed one are the
205+
// same number here, so the adapter matches both at once.
206+
assert_eq!(basis_noise_expr(x, y, &p, &tables), os * f64::from(basis));
207+
}
208+
}
158209
}
159210
}

crates/fmw-wasm/src/lib.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -744,9 +744,10 @@ pub extern "C" fn checksum_eval_pipeline(
744744
offset_x,
745745
};
746746
let tables = basis_noise::tables_from_seed(seed0, seed1);
747-
// f64, not f32: `basis_noise_expr` returns the un-narrowed f64 product
748-
// that the TypeScript returns, and the TypeScript memos hold a `number`.
749-
// See the note on `basis_noise_expr` and #269.
747+
// f64, not f32: `basis_noise_expr` narrows the product to f32 (#269) but
748+
// returns it widened, exactly as the TypeScript does, and the TypeScript
749+
// memos hold a `number`. The memo element type tracks the memo, not the
750+
// arithmetic. See the note on `basis_noise_expr`.
750751
let mut region = MemoRegion::<f64>::new();
751752
let mut slot = MemoXy::<f64>::new();
752753

src/noise/eval/primitives.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { basisNoise, basisNoiseTablesFromSeed, type BasisNoiseTables } from "../basisNoise";
2+
import { f32 } from "./f32";
23

34
export interface BasisExprParams {
45
/** Map seed (basis seed word). */
@@ -19,6 +20,40 @@ export interface BasisExprParams {
1920
* adapter maps world `(x, y)` through `((x + offset_x) * input_scale, y *
2021
* input_scale)` and multiplies by `output_scale` - exactly the game's DSL. Pass
2122
* prebuilt `tables` to skip the per-seed derivation when sweeping a grid.
23+
*
24+
* ## The output scale is narrowed twice, and both are needed (#269)
25+
*
26+
* The game evaluates this as `f32(f32(output_scale) * basis)`. That is BOTH
27+
* cases of the two-case rule in {@link f32} at one call site, which is why
28+
* neither half alone reaches the game:
29+
*
30+
* - **The CONSTANT.** `output_scale` is a program literal the engine holds at
31+
* f32. Writing `0.6` in TypeScript is the f64 0.59999999999999997780, and no
32+
* amount of rounding the product recovers that.
33+
* - **The PRODUCT.** `basis_noise` returns an f32 and the multiply is its own
34+
* f32 operation, so its result is f32 before anything downstream reads it.
35+
*
36+
* Graded against the game at 196 positions and five output scales in
37+
* `test/basisOutputScale.spec.ts`. Exact f64 equality, never a bound - both
38+
* sides produce f32 values, so a tolerance would pass for every candidate model
39+
* at once and measure nothing (#162). The scores that settled it:
40+
*
41+
* ```text
42+
* f32(f32(output_scale) * basis) 196/196 at all five scales <- the game
43+
* f32(output_scale * basis) 196, 110, 151, 196, 196 <- #269's proposal
44+
* output_scale * basis 196, 28, 6, 96, 1 <- what shipped
45+
* f32(output_scale) * basis 196, 0, 0, 0, 1
46+
* ```
47+
*
48+
* **A power-of-two `output_scale` is immune** and cannot grade this: multiplying
49+
* an f32 by one is a pure exponent shift, so it can never leave the f32 grid.
50+
* That is why the older `oracle-basis` fixture, captured at `output_scale = 1`,
51+
* could not answer the question and a new capture had to.
52+
*
53+
* The `input_scale` product is deliberately NOT narrowed here. It decides which
54+
* point gets sampled rather than what the product rounds to, and narrowing it
55+
* would evaluate a DIFFERENT point. Whether the game holds `input_scale` at f32
56+
* too is a separate, unmeasured question tracked on #269.
2257
*/
2358
export function basisNoiseExpr(
2459
x: number,
@@ -27,8 +62,8 @@ export function basisNoiseExpr(
2762
tables: BasisNoiseTables = basisNoiseTablesFromSeed(params.seed0, params.seed1),
2863
): number {
2964
const offsetX = params.offsetX ?? 0;
30-
return (
31-
params.outputScale *
32-
basisNoise((x + offsetX) * params.inputScale, y * params.inputScale, tables)
65+
return f32(
66+
f32(params.outputScale) *
67+
basisNoise((x + offsetX) * params.inputScale, y * params.inputScale, tables),
3368
);
3469
}

src/noise/wasm/engine.wasm

5 Bytes
Binary file not shown.

test/basisOutputScale.spec.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,17 @@ describe("basis_noise output_scale narrowing (#269)", () => {
8686
});
8787

8888
/**
89-
* And the current port is the worst of the four wherever the question can be
90-
* asked at all. These counts are what #269 is asking to change.
89+
* **#269 is fixed, and this is the assertion that graded it.** Before the fix
90+
* `basisNoiseExpr` returned the un-narrowed product and scored
91+
* `[196, 28, 6, 96, 1]` - the worst of the four models wherever the question
92+
* can be asked at all. It now IS `f32ScaleAndProduct`, so it reproduces the
93+
* game at every position of every case.
9194
*
92-
* **This assertion is expected to go GREEN-to-RED when #269 is fixed**, and
93-
* that is deliberate: the fix turns `basisNoiseExpr` into
94-
* `f32ScaleAndProduct`, and the whole point of a frozen count is that the
95-
* change announces itself. When it does, replace these with `[196, 196, 196,
96-
* 196, 196]` rather than loosening anything.
95+
* The count was frozen rather than bounded precisely so the change would
96+
* announce itself here, and it did. If it ever moves off a full house again,
97+
* read it - do not loosen it.
9798
*/
98-
it("the shipped basisNoiseExpr scores this, today", () => {
99+
it("the shipped basisNoiseExpr reproduces the game at every output scale", () => {
99100
const got = fixture.cases.map((c) => {
100101
let exact = 0;
101102
for (let i = 0; i < fixture.positions.length; i++) {
@@ -115,7 +116,7 @@ describe("basis_noise output_scale narrowing (#269)", () => {
115116
}
116117
return exact;
117118
});
118-
expect(got).toEqual([196, 28, 6, 96, 1]);
119+
expect(got).toEqual([N, N, N, N, N]);
119120
});
120121

121122
/**

0 commit comments

Comments
 (0)