diff --git a/crates/sds-core/examples/perf.rs b/crates/sds-core/examples/perf.rs index d9555bb..529430e 100644 --- a/crates/sds-core/examples/perf.rs +++ b/crates/sds-core/examples/perf.rs @@ -960,8 +960,10 @@ sweep fixture: {} stands x {} enemies x {positions} positions = {} pairs", }, ); - // What the sweep produced, and what collapsing it costs. `rank` is 2.8% of - // thinking against the sweep's 96.9%, and this is the line that says so. + // What the sweep produced, and what collapsing it costs. The collapse runs + // at the mean, where it takes one walk of each `(stand, enemy)` set rather + // than a `powf` per value per column, and that put it back under 2% of + // thinking against the sweep's own cost. This is the line that says so. let mut cache = VolleyCache::new(); let swept = stands::score_stands(&mut cache, &scene.los, &mover, &scene.stands, &foes); let params = Params::default(); diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index a4d5ed3..7ade098 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -306,6 +306,13 @@ pub struct Collapse { impl Collapse { /// See [`Prospect::collapse`]. Same arithmetic, reused buffers. pub fn of(&mut self, outcomes: &[Outcome], p: f32, sense: Sense) -> Prospect { + // The mean is the exponent every caller in this repository runs at, + // and at that exponent the generic path spends its time proving the + // identity `x^1 = x` six or twelve times per `(stand, enemy)` pair. + // See [`mean_collapse`] for why the two answer the same bits. + if p == 1.0 { + return mean_collapse(outcomes, sense); + } Prospect { expected_damage: self.pull(outcomes, |o| o.expected_damage, p, sense), p_kill: self.pull(outcomes, |o| o.p_kill, p, sense), @@ -332,6 +339,87 @@ impl Collapse { } } +/// [`Collapse::of`] at `p = 1`, all six channels in one walk of the set. +/// +/// **The same floats, not merely the same value.** At `p = 1` the generic path +/// computes `sum(max(v, 0)^1)/n`, then raises that to `1.0/1.0`. C99 Annex F +/// and IEEE 754 both specify `pow(x, 1) = x` for every `x`, so both `powf` +/// calls are the identity and what is left is `sum(max(v, 0))/n` - the same +/// addends, added in the same order, so the same rounding. A loss keeps its +/// dual exactly as [`robust_mean_in`] writes it: the ceiling is the same +/// left-to-right `max` fold over the raw column, the mirror is the same +/// `(ceiling - v).max(0.0)`, and the mean of it is summed in position order. +/// +/// What changes is the walk. The generic path copies each of the six columns +/// into a scratch `Vec` and walks it once for a gain and twice for a loss - +/// six or twelve passes over the set plus six or twelve copies of it, with a +/// `powf` per value. This walks the set once for a gain and twice for a loss, +/// with six accumulators live, and copies nothing. +fn mean_collapse(outcomes: &[Outcome], sense: Sense) -> Prospect { + if outcomes.is_empty() { + // `power_mean` is zero on an empty set and so is the dual. Nothing to + // be pessimistic about. + return Prospect::default(); + } + let n = outcomes.len() as f32; + match sense { + Sense::Gain => { + let (mut damage, mut kill, mut mission, mut breaches, mut value, mut heat) = + (0.0f32, 0.0f32, 0.0f32, 0.0f32, 0.0f32, 0.0f32); + for o in outcomes { + damage += o.expected_damage.max(0.0); + kill += o.p_kill.max(0.0); + mission += o.p_mission_kill.max(0.0); + breaches += o.expected_breaches.max(0.0); + value += o.value_destroyed.max(0.0); + heat += (o.heat as f32).max(0.0); + } + Prospect { + expected_damage: damage / n, + p_kill: kill / n, + p_mission_kill: mission / n, + expected_breaches: breaches / n, + value_destroyed: value / n, + heat: heat / n, + } + } + Sense::Loss => { + // The ceiling each column is reflected through: the same fold from + // negative infinity that `robust_mean_in` takes. + let inf = f32::NEG_INFINITY; + let (mut damage, mut kill, mut mission, mut breaches, mut value, mut heat) = + (inf, inf, inf, inf, inf, inf); + for o in outcomes { + damage = damage.max(o.expected_damage); + kill = kill.max(o.p_kill); + mission = mission.max(o.p_mission_kill); + breaches = breaches.max(o.expected_breaches); + value = value.max(o.value_destroyed); + heat = heat.max(o.heat as f32); + } + let ceilings = (damage, kill, mission, breaches, value, heat); + let (mut damage, mut kill, mut mission, mut breaches, mut value, mut heat) = + (0.0f32, 0.0f32, 0.0f32, 0.0f32, 0.0f32, 0.0f32); + for o in outcomes { + damage += (ceilings.0 - o.expected_damage).max(0.0); + kill += (ceilings.1 - o.p_kill).max(0.0); + mission += (ceilings.2 - o.p_mission_kill).max(0.0); + breaches += (ceilings.3 - o.expected_breaches).max(0.0); + value += (ceilings.4 - o.value_destroyed).max(0.0); + heat += (ceilings.5 - o.heat as f32).max(0.0); + } + Prospect { + expected_damage: ceilings.0 - damage / n, + p_kill: ceilings.1 - kill / n, + p_mission_kill: ceilings.2 - mission / n, + expected_breaches: ceilings.3 - breaches / n, + value_destroyed: ceilings.4 - value / n, + heat: ceilings.5 - heat / n, + } + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Blocked { /// No line of sight, or one the modifiers make impossible. Full cover @@ -2830,6 +2918,85 @@ mod tests { } } + /// The mean's own path and the general one must agree to the bit. + /// + /// Not "to within an epsilon": [`rank`] feeds the bot's choice, and a + /// reduction that comes out one ulp apart can reorder a frontier. The + /// reference is [`robust_mean`] column by column, which is exactly what the + /// general path does, run at exponents either side of one so the fast path + /// is the only thing under test. + #[test] + fn the_mean_path_and_the_general_one_agree_to_the_bit() { + // Zeros for the absent positions, a column that is flat, a column with + // one spike, an integer heat, and a set of length one. + let sets: Vec> = vec![ + vec![], + vec![Outcome::default()], + vec![Outcome::default(), Outcome::default(), Outcome::default()], + vec![ + Outcome { + expected_damage: 13.375, + p_kill: 0.0, + p_mission_kill: 0.125, + expected_breaches: 0.0, + value_destroyed: 271.5, + heat: 17, + }, + Outcome::default(), + Outcome { + expected_damage: 0.1, + p_kill: 0.3333333, + p_mission_kill: 0.0, + expected_breaches: 2.5, + value_destroyed: 0.0, + heat: 3, + }, + Outcome { + expected_damage: 0.1, + p_kill: 0.3333333, + p_mission_kill: 0.9, + expected_breaches: 1.0e-8, + value_destroyed: 1.0e7, + heat: 0, + }, + ], + ]; + + for outcomes in &sets { + for sense in [Sense::Gain, Sense::Loss] { + let fast = Prospect::collapse(outcomes, 1.0, sense); + let column = |get: fn(&Outcome) -> f32| { + let values: Vec = outcomes.iter().map(get).collect(); + robust_mean(&values, 1.0, sense) + }; + let general = Prospect { + expected_damage: column(|o| o.expected_damage), + p_kill: column(|o| o.p_kill), + p_mission_kill: column(|o| o.p_mission_kill), + expected_breaches: column(|o| o.expected_breaches), + value_destroyed: column(|o| o.value_destroyed), + heat: column(|o| o.heat as f32), + }; + let bits = |p: &Prospect| { + [ + p.expected_damage.to_bits(), + p.p_kill.to_bits(), + p.p_mission_kill.to_bits(), + p.expected_breaches.to_bits(), + p.value_destroyed.to_bits(), + p.heat.to_bits(), + ] + }; + assert_eq!( + bits(&fast), + bits(&general), + "{sense:?} over {} outcomes", + outcomes.len() + ); + } + } + } + #[test] fn collapse_runs_field_by_field() { let outcomes = [ @@ -4463,6 +4630,32 @@ mod tests { assert_eq!(dealt, target.dealt(), "dealt disagrees on {}", target.id); assert_eq!(taken, target.taken(), "taken disagrees on {}", target.id); assert_eq!(dealt.len(), target.positions as usize); + + // And the collapse at the mean agrees with the general path to + // the bit, on the sets the sweep actually produces rather than + // on a fixture chosen to suit it. + for (set, sense) in [(&dealt, Sense::Gain), (&taken, Sense::Loss)] { + let column = |get: fn(&Outcome) -> f32| { + let values: Vec = set.iter().map(get).collect(); + robust_mean(&values, 1.0, sense).to_bits() + }; + let fast = Prospect::collapse(set, 1.0, sense); + assert_eq!( + fast.expected_damage.to_bits(), + column(|o| o.expected_damage) + ); + assert_eq!(fast.p_kill.to_bits(), column(|o| o.p_kill)); + assert_eq!(fast.p_mission_kill.to_bits(), column(|o| o.p_mission_kill)); + assert_eq!( + fast.expected_breaches.to_bits(), + column(|o| o.expected_breaches) + ); + assert_eq!( + fast.value_destroyed.to_bits(), + column(|o| o.value_destroyed) + ); + assert_eq!(fast.heat.to_bits(), column(|o| o.heat as f32)); + } stored += target.present.len() as u32; absent += target.positions - target.present.len() as u32; } @@ -4476,6 +4669,7 @@ mod tests { id: 9, positions: 2, present: Vec::new(), + absent: vec![0; 2], outgoing: ReasonCounts::default(), incoming: ReasonCounts::default(), range: Spread::default(),