@@ -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