Skip to content

Commit a8bd84c

Browse files
wormeymanclaude
andauthored
Sweep Vulcanus cliffs over the volcanism control: the residual moves with the field (#403)
A lever on the INPUT to the cliff rule with the rule held fixed (#84). Neither vulcanus_volcanism slider touches the cliff rule - frequency is the input scale of the mountain and crack noise, size sets the volcano spot radius, spacing and density - so a sweep over them changes the elevation the cliff bands sit on while placement, both collision tests and the ore rule stay put. scripts/probes/vulcanus-cliff-volcanism/capture.ts is the first cliff capture through factorio-oracle. It reuses buildCliffControlLua from test/oracle/oracle.ts verbatim (forced-seed create_surface, the same one-drain chunk protocol, autoplace_controls read back off the surface) and runs one create per arm and region at 2.1.17, 2 seconds each. Four arms - default, frequency 0.5, frequency 2, size 3 - over the three regions of oracle-vulcanus-cliff-entities. Graded by vulcanus_cliffs_track_the_volcanism_sliders in fixtures.rs with the same four counts #307's table uses; no engine source changed and engine.wasm is byte-identical. Settled before any count was read: the default arm reproduces the 2.1.12 fixture cell for cell at 2.1.17 (283/885/409, same order), so the game did not move and that fixture keeps its stamp. R1 [0,0] is blind to volcanism on both sides - the engine moves 0 of 65,536 cliffs-view pixels there at any setting, the game places the same 283 cliffs in every arm, and the port scores 277/4/2/2 in every arm - because the whole square is inside the starting area. It is the control. And the 38 cells #307's table leaves out are all region-boundary entities from find_entities_filtered's bounding-box select, none with an orientation the port lacks; they are counted per arm as `unscored`. The sweep, shipping model, R2 + R3 errors over comparable cells: default 41 of 1248 3.29% frequency 0.5 21 of 1359 1.55% frequency 2 23 of 793 2.90% size 3 14 of 959 1.46% Two arms halve the rate, each about 2.9 sigma from default, so the residual depends on the elevation input and is not a fixed placement defect. It is not monotonic in feature scale either: a scale- proportional field error (#83's shape) predicts frequency 2 worst in both regions, and it is worst in R3 only and better than default in R2. With 14 to 41 events per arm that is a factor of two at 2.9 sigma and nothing finer. Full table and reading in docs/noise/vulcanus-cliffs-NOTES.md. Claude-Session: https://claude.ai/code/session_01EadT1PyhSjDbjJwvLfo3zr Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1ac40af commit a8bd84c

5 files changed

Lines changed: 34826 additions & 1 deletion

File tree

crates/fmw-noise/src/fixtures.rs

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4279,6 +4279,315 @@ fn places_every_vulcanus_cliff_where_the_game_places_it() {
42794279
);
42804280
}
42814281

4282+
/// One region of one volcanism arm, in the four counts #84 and #307 track,
4283+
/// plus the cells those counts leave out.
4284+
///
4285+
/// **Same definitions as
4286+
/// [`the_apply_stage_beats_the_crossing_stage_on_three_counts_and_loses_on_none`]**,
4287+
/// so a row here reads against that table directly. `missing` is NOT
4288+
/// `game - matched` from [`CliffScore`]: that counts every `cliff-vulcanus`
4289+
/// the game placed, and 38 of the 1569 at the default sit OUTSIDE the region -
4290+
/// `find_entities_filtered{area}` selects on the entity's bounding box, so a
4291+
/// cliff centred just past the edge is in the dump. Measured 2026-09-07: all
4292+
/// 38 are boundary cells and none carries an orientation the port lacks.
4293+
/// #307's table scores the 1531 whose centres are inside and this does too;
4294+
/// `unscored` keeps the dropped ones visible per arm rather than silently
4295+
/// absorbed, and the filter on orientation stays so a new game orientation
4296+
/// would land there rather than in `missing`.
4297+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4298+
struct SweepRow {
4299+
/// Right place, right orientation.
4300+
matched: usize,
4301+
/// Right place, wrong orientation.
4302+
wrong: usize,
4303+
/// A cell the game does not have.
4304+
surplus: usize,
4305+
/// A cell the game has, with a codeable orientation, that the port lacks.
4306+
missing: usize,
4307+
/// Game cells outside the comparison: an orientation the port has no code
4308+
/// for, or a position outside the region bounds.
4309+
unscored: usize,
4310+
}
4311+
4312+
impl SweepRow {
4313+
const ZERO: Self = Self {
4314+
matched: 0,
4315+
wrong: 0,
4316+
surplus: 0,
4317+
missing: 0,
4318+
unscored: 0,
4319+
};
4320+
4321+
fn add(self, other: Self) -> Self {
4322+
Self {
4323+
matched: self.matched + other.matched,
4324+
wrong: self.wrong + other.wrong,
4325+
surplus: self.surplus + other.surplus,
4326+
missing: self.missing + other.missing,
4327+
unscored: self.unscored + other.unscored,
4328+
}
4329+
}
4330+
}
4331+
4332+
/// Score one region under the SHIPPING model at the sliders `ctx` carries.
4333+
///
4334+
/// The placement is built exactly as [`score_vulcanus_cliffs`]'s `Shipping`
4335+
/// arm builds it; only the counting differs, per [`SweepRow`].
4336+
fn sweep_score(region: &Json, cliffs: &[Json], ctx: &crate::eval::ctx::EvalCtx) -> SweepRow {
4337+
let seed0 = ctx.seed0;
4338+
let base = VulcanusBase::with_host_trig(ctx);
4339+
let biomes = base.biomes_with_host_trig();
4340+
let stack = VulcanusStack::with_host_trig(&base, &biomes);
4341+
let fields = VulcanusCliffFields::new(&stack, seed0);
4342+
let lava = VulcanusLavaTiles::new(&stack);
4343+
let ore = VulcanusOreRejection::new(&stack, &ctx.vulcanus_resource_controls);
4344+
let bands = CliffBands {
4345+
elevation0: VULCANUS_CLIFF_ELEVATION_0,
4346+
interval: VULCANUS_CLIFF_ELEVATION_INTERVAL,
4347+
smoothing: VULCANUS_CLIFF_SMOOTHING,
4348+
reject_at_crossing_stage: true,
4349+
..CliffBands::default()
4350+
};
4351+
let (x0, y0) = (region.get("x0").as_f64(), region.get("y0").as_f64());
4352+
let (x1, y1) = (region.get("x1").as_f64(), region.get("y1").as_f64());
4353+
4354+
let mut row = SweepRow::ZERO;
4355+
let mut game: BTreeMap<(u64, u64), u8> = BTreeMap::new();
4356+
for e in cliffs {
4357+
if e.get("name").as_str() != "cliff-vulcanus" {
4358+
continue;
4359+
}
4360+
let (x, y) = (e.get("x").as_f64(), e.get("y").as_f64());
4361+
let want = e.get("orientation").as_str();
4362+
let id = CLIFF_ORIENTATION_NAMES.iter().position(|n| *n == want);
4363+
match id {
4364+
Some(id) if x >= x0 && x < x1 && y >= y0 && y < y1 => {
4365+
game.insert((x.to_bits(), y.to_bits()), id as u8);
4366+
}
4367+
_ => row.unscored += 1,
4368+
}
4369+
}
4370+
4371+
let port: BTreeMap<(u64, u64), u8> = CliffPlacement::new(&fields, bands)
4372+
.with_tile_collision(&lava)
4373+
.with_cell_rejection(&ore)
4374+
.placed_cells(x0, y0, x1, y1)
4375+
.iter()
4376+
.filter_map(|c| cliff_orientation_for_code(c.code).map(|id| (cell_key(c), id)))
4377+
.collect();
4378+
4379+
for (k, id) in &port {
4380+
match game.get(k) {
4381+
None => row.surplus += 1,
4382+
Some(want) if want == id => row.matched += 1,
4383+
Some(_) => row.wrong += 1,
4384+
}
4385+
}
4386+
row.missing = game.keys().filter(|k| !port.contains_key(*k)).count();
4387+
row
4388+
}
4389+
4390+
/// The Vulcanus cliff placement under the VOLCANISM sweep - the same three
4391+
/// regions as [`places_every_vulcanus_cliff_where_the_game_places_it`], captured
4392+
/// at four settings of the `vulcanus_volcanism` control (#84).
4393+
///
4394+
/// Neither volcanism slider touches the cliff rule. Frequency is the input
4395+
/// scale of the mountain and crack noise; size sets the volcano spot radius,
4396+
/// spacing and density. Both move the ELEVATION the cliff bands sit on and
4397+
/// nothing else, so the sweep changes the input to the cliff rule while the
4398+
/// rule, both collision tests and the ore rule stay fixed. That makes it a
4399+
/// control that can fail: if the residual scales with the arm, it lives on the
4400+
/// elevation or multisample side; if it stays flat, it lives in placement or
4401+
/// connection (#307).
4402+
///
4403+
/// Three things are asserted before any count is read:
4404+
///
4405+
/// - **The default arm IS the 2.1.12 fixture**, cell for cell and in the same
4406+
/// order, at 2.1.17. So the game did not move on these regions between the
4407+
/// two versions, and this capture's protocol is the old one's.
4408+
/// - **R1 `[0,0]` is the control, on BOTH sides.** It sits inside the starting
4409+
/// area, where the volcano spots are excluded and the mountain noise is
4410+
/// flattened, so the game places the same 283 cliffs in every arm and the
4411+
/// port scores the same row in every arm. A sweep that graded R1 would grade
4412+
/// nothing, which is the window rule again.
4413+
/// - **R2 and R3 really moved on the game side** in every non-default arm, or
4414+
/// the override never reached the generator.
4415+
#[test]
4416+
fn vulcanus_cliffs_track_the_volcanism_sliders() {
4417+
let sweep = load_captured_at(
4418+
"test/fixtures/oracle-vulcanus-cliff-volcanism-sweep.seed123456.json",
4419+
"2.1.17",
4420+
);
4421+
let reference = load_captured_at(
4422+
"test/fixtures/oracle-vulcanus-cliff-entities.seed123456.json",
4423+
"2.1.12",
4424+
);
4425+
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4426+
let seed0 = sweep.get("seed").as_f64() as u32;
4427+
assert_eq!(
4428+
seed0,
4429+
reference.get("seed").as_f64() as u32,
4430+
"same forced seed"
4431+
);
4432+
4433+
let arms = sweep.get("arms").as_array();
4434+
assert_eq!(arms.len(), 4, "default, frequency 0.5, frequency 2, size 3");
4435+
4436+
// A game cliff as the port would key it, orientation included.
4437+
let cell = |c: &Json| -> (u64, u64, String) {
4438+
(
4439+
c.get("x").as_f64().to_bits(),
4440+
c.get("y").as_f64().to_bits(),
4441+
c.get("orientation").as_str().to_owned(),
4442+
)
4443+
};
4444+
let game_cells = |case: &Json| -> Vec<(u64, u64, String)> {
4445+
case.get("cliffs")
4446+
.as_array()
4447+
.iter()
4448+
.filter(|c| c.get("name").as_str() == "cliff-vulcanus")
4449+
.map(cell)
4450+
.collect()
4451+
};
4452+
4453+
// The default arm is the 2.1.12 capture, cell for cell and in order.
4454+
let default_arm = &arms[0];
4455+
assert_eq!(default_arm.get("label").as_str(), "default");
4456+
let default_cases = default_arm.get("cases").as_array();
4457+
let reference_cases = reference.get("cases").as_array();
4458+
assert_eq!(default_cases.len(), 3);
4459+
assert_eq!(reference_cases.len(), 3);
4460+
for (i, (a, b)) in default_cases.iter().zip(reference_cases).enumerate() {
4461+
assert_eq!(
4462+
game_cells(a),
4463+
game_cells(b),
4464+
"region {i}: the 2.1.17 default arm must reproduce the 2.1.12 capture"
4465+
);
4466+
}
4467+
let r1_game = game_cells(&default_cases[0]);
4468+
let r2_game = game_cells(&default_cases[1]);
4469+
let r3_game = game_cells(&default_cases[2]);
4470+
4471+
let mut rows: Vec<(String, [SweepRow; 3], SweepRow)> = Vec::new();
4472+
for arm in arms {
4473+
let label = arm.get("label").as_str().to_owned();
4474+
let frequency = arm.get("frequency").as_f64();
4475+
let size = arm.get("size").as_f64();
4476+
// The surface's own read-back, so an override that did not apply cannot
4477+
// pass as a setting that does not matter.
4478+
let reported = arm.get("reported");
4479+
assert_eq!(
4480+
reported.get("frequency").as_f64(),
4481+
frequency,
4482+
"{label}: read-back"
4483+
);
4484+
assert_eq!(reported.get("size").as_f64(), size, "{label}: read-back");
4485+
4486+
let cases = arm.get("cases").as_array();
4487+
assert_eq!(cases.len(), 3, "{label}: three regions");
4488+
assert_eq!(game_cells(&cases[0]), r1_game, "{label}: R1 is the control");
4489+
if label != "default" {
4490+
assert_ne!(game_cells(&cases[1]), r2_game, "{label}: R2 must move");
4491+
assert_ne!(game_cells(&cases[2]), r3_game, "{label}: R3 must move");
4492+
}
4493+
4494+
let mut ctx = crate::eval::ctx::EvalCtx::new(seed0);
4495+
ctx.vulcanus_volcanism_frequency = frequency;
4496+
ctx.vulcanus_volcanism_size = size;
4497+
let mut per_region = [SweepRow::ZERO; 3];
4498+
for (i, case) in cases.iter().enumerate() {
4499+
per_region[i] = sweep_score(case.get("region"), case.get("cliffs").as_array(), &ctx);
4500+
}
4501+
let total = per_region[0].add(per_region[1]).add(per_region[2]);
4502+
eprintln!(
4503+
"{label:>14}: R1 {:?} R2 {:?} R3 {:?} total {:?}",
4504+
per_region[0], per_region[1], per_region[2], total
4505+
);
4506+
rows.push((label, per_region, total));
4507+
}
4508+
4509+
// The port agrees R1 is blind: the same row in every arm.
4510+
let r1_default = rows[0].1[0];
4511+
for (label, per_region, _) in &rows {
4512+
assert_eq!(
4513+
per_region[0], r1_default,
4514+
"{label}: the port's R1 row must not move either"
4515+
);
4516+
}
4517+
// The default arm's totals are #307's shipping row, restated - the same
4518+
// 1504 / 21 / 22 / 6 over the same 1531 comparable cells, with the 38 the
4519+
// table leaves out now counted.
4520+
let row = |matched, wrong, surplus, missing, unscored| SweepRow {
4521+
matched,
4522+
wrong,
4523+
surplus,
4524+
missing,
4525+
unscored,
4526+
};
4527+
assert_eq!(rows[0].2, row(1504, 21, 22, 6, 38), "default arm totals");
4528+
4529+
// The frozen sweep, measured 2026-09-07. Read a moved number, do not
4530+
// adjust it.
4531+
//
4532+
// | arm | R2 (matched/wrong/surplus/missing) | R3 | total, of comparable cells |
4533+
// | ------------- | ---------------------------------- | ------------- | -------------------------- |
4534+
// | default | 842 / 16 / 19 / 3 | 385 / 1 / 1 / 1 | 49 of 1531 = 3.2% |
4535+
// | frequency 0.5 | 720 / 7 / 10 / 2 | 628 / 2 / 0 / 0 | 29 of 1642 = 1.8% |
4536+
// | frequency 2 | 252 / 4 / 4 / 0 | 528 / 8 / 6 / 1 | 31 of 1076 = 2.9% |
4537+
// | size 3 | 531 / 5 / 4 / 2 | 419 / 1 / 1 / 1 | 22 of 1242 = 1.8% |
4538+
//
4539+
// R1 is 277 / 4 / 2 / 2 in every arm, by the control above.
4540+
//
4541+
// What it says, and what it cannot: the residual MOVES with the elevation
4542+
// input - the non-R1 error rate spans 1.5% to 3.3% across arms - so it is
4543+
// not a fixed placement-side defect that the elevation leaves alone. But
4544+
// it is not monotonic in frequency either (default is the worst arm, not
4545+
// frequency 2), and with 14 to 41 residual events per arm the resolution
4546+
// is about two sigma. It localises the residual to "depends on the field",
4547+
// not to a term. `unscored` is game cliffs on the region boundary, every
4548+
// one of them - see the volcanism sweep section (2026-09-07) of
4549+
// `docs/noise/vulcanus-cliffs-NOTES.md`.
4550+
let expected: [(&str, [SweepRow; 3]); 4] = [
4551+
(
4552+
"default",
4553+
[
4554+
row(277, 4, 2, 2, 0),
4555+
row(842, 16, 19, 3, 24),
4556+
row(385, 1, 1, 1, 14),
4557+
],
4558+
),
4559+
(
4560+
"frequency 0.5",
4561+
[
4562+
row(277, 4, 2, 2, 0),
4563+
row(720, 7, 10, 2, 15),
4564+
row(628, 2, 0, 0, 12),
4565+
],
4566+
),
4567+
(
4568+
"frequency 2",
4569+
[
4570+
row(277, 4, 2, 2, 0),
4571+
row(252, 4, 4, 0, 10),
4572+
row(528, 8, 6, 1, 12),
4573+
],
4574+
),
4575+
(
4576+
"size 3",
4577+
[
4578+
row(277, 4, 2, 2, 0),
4579+
row(531, 5, 4, 2, 26),
4580+
row(419, 1, 1, 1, 14),
4581+
],
4582+
),
4583+
];
4584+
for ((label, per_region, total), (want_label, want)) in rows.iter().zip(expected) {
4585+
assert_eq!(label, want_label);
4586+
assert_eq!(*per_region, want, "{label}: per-region rows");
4587+
assert_eq!(*total, want[0].add(want[1]).add(want[2]), "{label}: totals");
4588+
}
4589+
}
4590+
42824591
/// The Vulcanus cliff fields against the game's own samples at the game's own
42834592
/// lattice - 12,675 corners across three regions.
42844593
///

0 commit comments

Comments
 (0)