Skip to content

Commit e98904d

Browse files
wormeymanclaude
andauthored
Port the enemy footprint decision-boundary probe to Rust (#227) (#353)
Port-first entry 2. `test/enemyBaseField.spec.ts` carries two claims, not one, and only the first has a Rust counterpart. `worstAbs`/`worstRel` grade the field's values, and `fixtures.rs` already reproduces those at both seeds in three magnitude buckets. The second claim is `footprintDisagreements`: port and game never fall on opposite sides of a cut at 0.05 through `min(v, ENEMY_PLACEMENT_CAP)`. Nothing in Rust graded that. The gap is real rather than theoretical, because the two checks cannot see each other. An aggregate tolerance passes a residual that still moves a position across the cut, and a cut is what the overlay does with this field. The nearest Rust check, `the_enemy_fixture_is_mostly_basement_...`, compares positive counts, so two positions could swap sides and the count would not move. Planted rather than predicted: | plant | result | | --- | --- | | bias the port by 0.011 | `a value sits 8.04e-4 from the cut` | | bias the port by 0.03 | `port and game fall on opposite sides` - 3 at seed 123456 | The second is the one that matters: 0.03 is 33x below the deleted spec's own `ABS_TOL` of 1.0, so the aggregate arms would have passed it. Two guards beside the probe, because a zero on its own can be luck. The count of positions inside the cut is frozen per seed - 39 of 1032 at seed 123456, 37 at seed 777771 - so a port returning a constant cannot satisfy it. And the nearest value to the cut is required to stay above 1e-3, so the f32 read cannot be what decides a side. This unblocks deleting `test/enemyBaseField.spec.ts`, which imports `src/noise/enemies/enemyBaseField.ts` and therefore cannot outlive the #227 deletion. The spec stays until then; the coverage is now in both places. Claude-Session: https://claude.ai/code/session_01UVcbv1pAhPUoCC6aBwZUtg Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 46c2032 commit e98904d

1 file changed

Lines changed: 73 additions & 0 deletions

File tree

crates/fmw-noise/src/fixtures.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6068,6 +6068,7 @@ use crate::enemies::catalog::{
60686068
ENEMY_PLACEMENT_CAP,
60696069
};
60706070
use crate::enemies::field::{EnemyBaseField, EnemyFieldParams};
6071+
use crate::eval::math::min2;
60716072

60726073
#[test]
60736074
fn reproduces_the_games_enemy_base_field_at_both_seeds() {
@@ -6202,6 +6203,78 @@ fn the_enemy_fixture_is_mostly_basement_so_the_probability_would_grade_nothing()
62026203
}
62036204
}
62046205

6206+
#[test]
6207+
fn the_port_and_the_game_never_straddle_the_footprint_cut() {
6208+
// The half of `test/enemyBaseField.spec.ts` that `worstAbs`/`worstRel` could
6209+
// not see, ported here for #227 before that spec goes with
6210+
// `src/noise/enemies/enemyBaseField.ts`.
6211+
//
6212+
// Both of those are aggregates. A residual small enough to pass them can
6213+
// still put the port on the far side of a cut from the game, and a cut is
6214+
// what the overlay actually does with this field. `PROBE_CUT` was
6215+
// `ENEMY_FOOTPRINT_THRESHOLD` until the overlay moved from thresholding the
6216+
// probability field to rolling against it, so it is a decision-boundary
6217+
// probe rather than a live threshold - the value it takes matters less than
6218+
// that some cut through the live part of the range is graded at all.
6219+
const PROBE_CUT: f64 = 0.05;
6220+
let in_footprint = |v: f64| min2(v, ENEMY_PLACEMENT_CAP) >= PROBE_CUT;
6221+
6222+
let fixture = load_captured_at("test/fixtures/oracle-enemy-base.seed123456.json", "2.1.11");
6223+
let positions = fixture_positions(&fixture, "positions");
6224+
6225+
// (seed, positions inside the cut). Measured, not chosen.
6226+
let expected: [(u32, usize); 2] = [(123_456, 39), (777_771, 37)];
6227+
for (case, &(seed, want_inside)) in fixture.get("cases").as_array().iter().zip(expected.iter())
6228+
{
6229+
assert_eq!(case.get("seed").as_f64() as u32, seed, "case order");
6230+
let field = EnemyBaseField::new(&EnemyFieldParams::defaults(seed));
6231+
let values = case.get("values").as_array();
6232+
6233+
let mut disagreements = 0usize;
6234+
let mut inside = 0usize;
6235+
let mut margin = f64::INFINITY;
6236+
for (i, (x, y)) in positions.iter().enumerate() {
6237+
let want = values[i].as_f64();
6238+
let port = f64::from(field.field(snap_coord(*x), snap_coord(*y)) as f32);
6239+
if in_footprint(port) != in_footprint(want) {
6240+
disagreements += 1;
6241+
}
6242+
if in_footprint(want) {
6243+
inside += 1;
6244+
}
6245+
margin = margin
6246+
.min((min2(want, ENEMY_PLACEMENT_CAP) - PROBE_CUT).abs())
6247+
.min((min2(port, ENEMY_PLACEMENT_CAP) - PROBE_CUT).abs());
6248+
}
6249+
6250+
assert_eq!(
6251+
disagreements, 0,
6252+
"seed {seed}: port and game fall on opposite sides of the cut"
6253+
);
6254+
6255+
// Anti-vacuity, and it is the reason this is not a one-liner: the cut
6256+
// has to actually separate this fixture. A port that returned a
6257+
// constant would satisfy the line above on its own.
6258+
assert_eq!(
6259+
inside,
6260+
want_inside,
6261+
"seed {seed}: positions inside the cut, of {}",
6262+
positions.len()
6263+
);
6264+
assert!(
6265+
inside > 0 && inside < positions.len(),
6266+
"seed {seed}: the cut does not separate"
6267+
);
6268+
6269+
// And no value sits near the cut, so the f32 read above cannot be what
6270+
// decides a side. Without this the zero above could be luck.
6271+
assert!(
6272+
margin > 1e-3,
6273+
"seed {seed}: a value sits {margin:e} from the cut - rounding could flip it"
6274+
);
6275+
}
6276+
}
6277+
62056278
#[test]
62066279
fn every_enemy_distance_scalar_saturates_at_2400_tiles() {
62076280
// `enemy_intensity` clamps its distance at 2400, so the radius, quantity,

0 commit comments

Comments
 (0)