diff --git a/crates/sds-core/src/features/firing.rs b/crates/sds-core/src/features/firing.rs index 1ff4aa9..e4b88c1 100644 --- a/crates/sds-core/src/features/firing.rs +++ b/crates/sds-core/src/features/firing.rs @@ -108,6 +108,11 @@ impl<'a> Volley<'a> { /// a Mek and every one is a pile of edge cases; a rule that is simple and /// slightly conservative is worth more than one that is clever and /// occasionally absurd. + /// + /// Nor does it include dying. It is tempting to say a dead Mek has certainly + /// stopped fighting and fold [`Self::LETHAL`] in, and that was how this + /// worked; it makes the feature a superset of `p_kill` and therefore + /// unfittable beside it. See [`PMissionKill`]. const CRIPPLING: [usize; 2] = [hitloc::LL, hitloc::RL]; /// Damage that destroys the target outright, ignoring where it lands. @@ -213,7 +218,23 @@ impl Feature for PKill { } } -/// The chance the volley stops it fighting, which it can do without killing it. +/// The chance the volley takes a leg off. +/// +/// **A leg, and only a leg.** This used to be `P(a leg goes or the target +/// dies)`, and that union strictly contains [`PKill`] - so the two could never +/// be weighted apart, however much corpus was recorded. Measured on a 24-game +/// bench: identical on 2506 of 2700 candidates, `r = +0.989`, and lower than +/// `p_kill` on exactly none of them, which is what "strictly contains" looks +/// like from the outside. Fitting on that pair put the largest weight in the +/// vector on `value_destroyed` at -0.70, saying that breaking what makes the +/// enemy dangerous is bad - the arbitrary split a duplicated column produces, +/// the same failure as `los_in`/`los_out` at +1.40/-1.25. +/// +/// Death is `p_kill`'s job. A basis wants measurements that can disagree, and +/// these two genuinely do: a leg is 4 rolls in 36 against a centre torso's 7, +/// and carries a leg's health rather than a torso's, so a volley that +/// comfortably kills through a thin centre torso may have almost no chance of +/// taking a leg off. pub struct PMissionKill; impl Feature for PMissionKill { @@ -221,18 +242,16 @@ impl Feature for PMissionKill { type Input<'a> = Volley<'a>; const NAME: &'static str = "p_mission_kill"; const DESCRIPTION: &'static str = - "The chance a leg is destroyed, or the target dies, counting only the damage that lands there."; + "The chance this volley destroys a leg, counting only the damage that lands there."; fn measure(volley: &Volley<'_>) -> Score { if !volley.has_locations() { - return Score::probability(volley.damage.at_least(volley.kill_threshold())); + // No legs to model. Zero rather than the whole-unit kill threshold, + // which is what made this a copy of `p_kill` on every target the + // location model could not read. + return Score::probability(0.0); } - // Losing either leg does it, and so does dying: a target that is dead - // has certainly stopped fighting, so a mission kill can never read below - // a kill. - let crippled = volley.locations.any_destroyed(&Volley::CRIPPLING); - let killed = volley.locations.any_destroyed(&Volley::LETHAL); - Score::probability(crippled.max(killed)) + Score::probability(volley.locations.any_destroyed(&Volley::CRIPPLING)) } } @@ -618,15 +637,68 @@ mod tests { } #[test] - fn mission_kill_is_never_harder_than_killing() { - let (shooter, target) = pair(); + fn a_kill_and_a_mission_kill_are_different_events() { + // What replaced `mission_kill_is_never_harder_than_killing`. That test + // asserted `p_mission_kill >= p_kill`, which was not a property of the + // game but of the definition: the feature was `P(leg or death)`, a + // superset of `p_kill`, so the assertion could not fail and the two + // columns could not be told apart. On a real corpus they came out + // identical on 93% of candidates at r = +0.99. + // + // The property that matters now is that they can disagree, and in the + // direction the old assertion forbade. A target with a thin centre torso + // and heavy legs is much easier to kill than to cripple. + let (shooter, mut target) = pair(); + for location in target.locations.iter_mut() { + match location.name.as_str() { + "CT" => { + location.armor = 2; + location.internal = 2; + } + "LL" | "RL" => { + location.armor = 30; + location.internal = 20; + } + _ => {} + } + } let shots: Vec = (0..4).map(|_| shot(1, 2, 3, 10.0, 3)).collect(); let mut cache = EvCache::new(); let fired = Fired::new(&mut cache, &shooter, &shots, &target, Side::Front); let volley = fired.volley(&shooter, &target, &shots, Side::Front); + let kill = PKill::measure(&volley).get(); + let mission = PMissionKill::measure(&volley).get(); assert!( - PMissionKill::measure(&volley).get() >= PKill::measure(&volley).get(), - "taking a leg cannot be less likely than killing outright" + kill > mission, + "a paper centre torso behind armoured legs: kill {kill}, mission {mission}" + ); + } + + #[test] + fn many_small_packets_and_one_big_one_are_told_apart() { + // The other half of the same property: two volleys of comparable + // expected damage that the location model must score differently. + // Sixteen missiles of one point cannot reach any threshold; one big + // slug can. + let mut shooter = mek(1); + shooter.friendly = true; + shooter.weapons = vec![gun(1, 40.0, 1), rack(2, 20, 5, 16)]; + let target = mek(2); + let mut cache = EvCache::new(); + + let slug = vec![shot(1, 2, 3, 40.0, 1)]; + let spray = vec![Shot { + damage_per_packet: 1.0, + ..shot(2, 2, 3, 12.0, 5) + }]; + let punch = Fired::new(&mut cache, &shooter, &slug, &target, Side::Front); + let sand = Fired::new(&mut cache, &shooter, &spray, &target, Side::Front); + let punch_kill = PKill::measure(&punch.volley(&shooter, &target, &slug, Side::Front)).get(); + let sand_kill = PKill::measure(&sand.volley(&shooter, &target, &spray, Side::Front)).get(); + assert!(punch_kill > 0.0, "a 40-point slug can reach a head"); + assert_eq!( + sand_kill, 0.0, + "one-point missiles cannot destroy a location" ); } diff --git a/plan/features.md b/plan/features.md index e7eab9a..5ee993f 100644 --- a/plan/features.md +++ b/plan/features.md @@ -52,6 +52,30 @@ whether it can be used for learning. - [x] Firing, per location: `p_kill`, `p_mission_kill` and `overkill` measured against the location the damage reaches rather than against the target's total, plus `p_breach` and `value_destroyed`. See `hitloc` below +- [x] `p_mission_kill` is `P(a leg is destroyed)` and nothing else. It was + `P(a leg goes or the target dies)`, a union strictly containing `p_kill`, + so the two could not be weighted apart however much corpus was recorded: + identical on 2506 of 2700 bench candidates, `r = +0.989`, and lower than + `p_kill` on none of them. A fit on that pair put -0.70 on + `value_destroyed` - the largest weight in the vector, and it said breaking + what makes the enemy dangerous is bad. Same failure as `los_in`/`los_out` + at +1.40/-1.25 + +## A basis wants measurements that can disagree + +Two features that cannot disagree are one feature and a coin toss over how to +split its weight. The arithmetic is indifferent to the split, so the fit picks +one arbitrarily, and a large confident weight can appear on either half with the +opposite sign on the other. It has now happened twice, and both times the giveaway +was a weight whose *sign* was absurd rather than whose size was. + +The cheap check, before recording a corpus rather than after fitting one: for +each pair of features, the share of candidates where they are equal, and their +correlation. A pair over about 0.95 is one column. + +- [ ] Run that check as part of `sds catalogue` or the corpus manifest, over the + corpus being recorded. Both instances were found by hand, after a fit had + already been run and interpreted - [ ] Retire `weapon_concentration` once `p_breach` has a fitted weight. It compares the average packet against the biggest packet in the volley, which is a statement about the guns; `p_breach` asks the same question of diff --git a/weights/fitted-hitloc.json b/weights/fitted-hitloc.json new file mode 100644 index 0000000..d0f22e0 --- /dev/null +++ b/weights/fitted-hitloc.json @@ -0,0 +1,59 @@ +{ + "commit": "2617f2b", + "epoch": 3, + "fit": { + "agreement": 0.8791277258566979, + "credit": "ratio", + "decisions": 1605, + "excluded_dead": [ + "has_been_fired_upon", + "honour_broken", + "lost_a_unit" + ], + "excluded_local": [ + "damage_lead" + ], + "excluded_thin": {}, + "framing": "difference", + "gamma": 0.93, + "imitation_rows": 0, + "label": "bv_loss_inflicted", + "matches": 24, + "r_squared": 0.19708043287460386, + "ridge": 0.001, + "rows": 5804, + "tier": "team" + }, + "recorded": "2026-08-20T15:31:00Z", + "version": 1, + "weights": { + "ammo_spent": -0.05969109438546141, + "arc_spread": -0.16984382479489218, + "cohesion": 0.4346851513310221, + "concentration": -0.033096220971370376, + "cover_quality": 0.5678944186974415, + "elevation_gain": 0.9480195178034104, + "expected_damage": 0.2785517870257107, + "exposure": -0.26862298001086615, + "heat_incurred": -0.19780035976125945, + "los_in": 0.08395328219238765, + "los_out": 0.08395328219238765, + "overkill": 0.3092622473111493, + "p_breach": -0.09708271141181572, + "p_kill": -0.07309830972506916, + "p_mission_kill": 0.09519556962633638, + "p_psr_threshold": 0.30605382816762633, + "range_band_fit": 0.41957938933565575, + "rear_arc_gain": 0.21439792047683923, + "target_breach": 0.20058818146474755, + "target_current_bv": 0.2564396156301789, + "target_health": -0.4561815121966278, + "target_original_bv": -0.01783224808002117, + "target_skill": 0.719752715811938, + "target_threat": 0.12851801036394775, + "target_tonnage": 0.05910756628565524, + "tmm_gained": 0.41076168813350894, + "value_destroyed": -0.7044551244241511, + "weapon_concentration": 0.13266856855924541 + } +}