diff --git a/crates/sds-bot/src/unit.rs b/crates/sds-bot/src/unit.rs index 779474b..d03647d 100644 --- a/crates/sds-bot/src/unit.rs +++ b/crates/sds-bot/src/unit.rs @@ -1558,6 +1558,7 @@ mod tests { p_kill: 0.0, p_mission_kill: 0.0, value_destroyed: 0.0, + expected_criticals: 0.0, at_stake: 0.0, volley_heat: 0.0, move_heat: 0, diff --git a/crates/sds-core/examples/stands.rs b/crates/sds-core/examples/stands.rs index bc7f6d3..7098cc7 100644 --- a/crates/sds-core/examples/stands.rs +++ b/crates/sds-core/examples/stands.rs @@ -501,7 +501,8 @@ fn report( // the best target, in heat points. println!( " {} deal {:>6.2} take {:>6.2} p_kill {:.4} \ - mission {:.4} breaches {:.2} value {:>6.2} heat {:>5.2} \ + mission {:.4} breaches {:.2} crits {:.2} value {:>6.2} \ + heat {:>5.2} \ target {} [{}]", describe(score), score.offence.expected_damage, @@ -509,6 +510,7 @@ fn report( score.offence.p_kill, score.offence.p_mission_kill, score.offence.expected_breaches, + score.offence.expected_criticals, score.offence.value_destroyed, score.offence.heat, score diff --git a/crates/sds-core/examples/vignettes/firing.rs b/crates/sds-core/examples/vignettes/firing.rs index 086a1a1..4a38527 100644 --- a/crates/sds-core/examples/vignettes/firing.rs +++ b/crates/sds-core/examples/vignettes/firing.rs @@ -35,8 +35,8 @@ use sds_core::ev::EvCache; use sds_core::features::firing::{ - AmmoSpent, DamageLead, ExpectedDamage, HeatIncurred, Overkill, PBreach, PKill, PMissionKill, - PPsrThreshold, ValueDestroyed, + AmmoSpent, DamageLead, ExpectedCriticals, ExpectedDamage, HeatIncurred, Overkill, PBreach, + PKill, PMissionKill, PPsrThreshold, ValueDestroyed, }; use sds_core::features::{AmongCandidates, Feature}; use sds_core::wire::{Coord, Unit}; @@ -268,6 +268,29 @@ impl Documented for ValueDestroyed { } } +impl Documented for ExpectedCriticals { + fn example() -> Example { + Example { + shows: SCENE, + reading: "Criticals, not damage. The count is what gets *past* the armour \ + divided by what one packet carries, and it is the column `p_breach` \ + cannot give: `p_breach` says a location was opened and stops there, \ + this says what came through the hole. The shape follows \ + `expected_damage` - best where both guns are in bracket, worse inside \ + the Gauss rifle's minimum range - and the spread over it is far \ + steeper, 0.03 at the long end against 0.23 at the best hex where \ + expected damage only goes from 0.11 to 0.42. A location has to be \ + opened before anything can come through it, so the second gun coming \ + into bracket is aimed at armour the first has already been through. \ + This target is a 55-tonner well into a fight; one still behind whole \ + armour reads a flat zero at every range.", + figure: figure(along_the_column(|v| { + ExpectedCriticals::measure(&v.projected()).get() + })), + } + } +} + impl Documented for DamageLead { fn example() -> Example { Example { diff --git a/crates/sds-core/src/features/firing.rs b/crates/sds-core/src/features/firing.rs index 9fd362e..91265e2 100644 --- a/crates/sds-core/src/features/firing.rs +++ b/crates/sds-core/src/features/firing.rs @@ -222,6 +222,17 @@ impl<'a> Volley<'a> { 0.0 }, value_destroyed: self.locations.expected_value_destroyed(), + expected_criticals: if self.has_locations() { + self.locations.expected_criticals(self.damage.mean_packet()) + } else { + // No per-location armour, so nothing says where the armour + // ends. Zero rather than a whole-unit stand-in: a threshold + // against a total is the error this module was rewritten to + // remove, and it would be a worse error here than it is for + // `p_kill`, because criticals are the tail of the + // distribution rather than its middle. + 0.0 + }, at_stake: self.locations.value_at_stake(), volley_heat: self.heat() as f32, move_heat: 0, @@ -313,6 +324,9 @@ pub struct Projected<'a> { /// What the volley is expected to break, in [`crate::wire::Location`] /// value. pub value_destroyed: f32, + /// Criticals the volley is expected to roll, as a count. See + /// [`ExpectedCriticals`] for the arithmetic and what it leaves out. + pub expected_criticals: f32, /// What the target has to lose, in the same units. Zero means the /// observation carried no location contents, which is not evidence that the /// target is empty - `value_destroyed` reads zero rather than dividing. @@ -366,6 +380,7 @@ impl<'a> Projected<'a> { vector.measure::(self); vector.measure::(self); vector.measure::(self); + vector.measure::(self); vector.measure::(self); vector.extend(&self.heat_outlook().features()); vector @@ -731,6 +746,78 @@ impl Feature for ValueDestroyed { } } +/// How many criticals this volley is expected to roll. +/// +/// **The axis [`crate::ev`] argues for and stops short of computing.** Ten +/// points in one location and ten spread over six are the same mean and +/// completely different objects: the first opens a location, the second fishes +/// for criticals through one already open. `expected_damage` cannot tell them +/// apart, [`PBreach`] says only whether a location was opened, and neither says +/// what came through the hole. +/// +/// Three things in the plan want it and none can be answered without it. A +/// battle armour leg attack does four points, which is nearly noise, and is +/// held at no value at all rather than given a plausible constant - its payoff +/// is a through-armour critical. Matches end on criticals rather than on damage +/// totals, and [`PKill`] is damage-based, so the ammunition explosion that ends +/// a match in round three is not in it. And a kick or a punch into an open +/// location is the second half of that quotation exactly. +/// +/// # The arithmetic +/// +/// A hit past a location's armour rolls 2d6: 8-9 is one critical, 10-11 is two, +/// 12 is three or the limb comes off. That is +/// [`hitloc::CRITICALS_PER_INTERNAL_HIT`], 0.611, and the chance of any at all +/// is 15/36. +/// +/// **The roll is per hit and not per point**, so the internal damage is divided +/// by [`crate::ev::DamagePmf::mean_packet`]. That division is the one +/// approximation, it is exact only for a volley of one weapon type, and it is +/// stated on +/// [`LocationDamage::expected_criticals`](crate::hitloc::LocationDamage::expected_criticals) +/// along with what is excluded: through-armour criticals, armour-piercing and +/// tandem-charge rounds, and what a critical actually hits. +/// +/// # A count, not a value +/// +/// [`crate::wire::Location::value`] is the obvious weighting for "what did the +/// critical break" and the wrong one: its own documentation says the figures are +/// round numbers chosen to rank locations and that nothing downstream should +/// read them as calibrated. Weighting criticals by it would be reading it as +/// exactly that. The fitting decides what a count is worth, and expected +/// critical *value* is a different feature wanting its own argument. +pub struct ExpectedCriticals; + +/// The count [`ExpectedCriticals`] reads 1.00 at. +/// +/// Saturating, and chosen off the fixture rather than guessed. Measured over +/// one to six guns against the 55-tonner in `fixture::mek`, at full armour, at +/// half and stripped to structure: a single AC/20 into whole armour reads 0.13, +/// six of them read 1.48, and six into a machine with half its plates left read +/// 2.42. Three is where a volley has stopped being a shot and become a +/// dismantling. +/// +/// It is one-sided the way [`crate::ev::BUCKETS`] is, and the tail past it is +/// long: six LRM-20s into a Mek with no armour anywhere score 37.9. A volley +/// like that is already the best thing on the menu by every other measure, so +/// the column reading 1.00 rather than 12.6 costs nothing a decision can see - +/// and a ceiling set where that tail lives would put every ordinary volley in +/// the bottom thirtieth of the range, which is the thin-column failure +/// [`Overkill`] was rewritten to escape. +pub const CRITICALS_CEILING: f32 = 3.0; + +impl Feature for ExpectedCriticals { + type Norm = Bounded; + type Input<'a> = Projected<'a>; + const NAME: &'static str = "expected_criticals"; + const DESCRIPTION: &'static str = + "How many critical hits we expect this volley to roll on the target."; + + fn measure(projected: &Projected<'_>) -> Score { + Score::bounded(projected.expected_criticals, 0.0, CRITICALS_CEILING) + } +} + /// How this candidate's damage compares with the other candidates. /// /// The one `local` feature, and it is here on purpose: min-max across this @@ -1348,6 +1435,117 @@ mod tests { ); } + /// The same 55-tonner with every plate gone and its structure intact. + /// + /// `to_bare_structure` floors at one, so a packet of any size is already + /// inside. What `expected_criticals` is about. + fn stripped() -> Unit { + let mut them = mek(2); + for location in them.locations.iter_mut() { + location.armor = 0; + location.rear_armor = location.rear_armor.map(|_| 0); + } + them.armor = 0; + them + } + + fn criticals(shooter: &Unit, target: &Unit, shots: &[Shot]) -> f32 { + let mut cache = EvCache::new(); + let fired = Fired::new(&mut cache, shooter, shots, target, Side::Front); + fired + .volley(shooter, target, shots, Side::Front) + .projected() + .expected_criticals + } + + /// A shot that cannot reach structure anywhere rolls nothing. + /// + /// The thinnest plate on the fixture is the head's nine, and one five-point + /// gun cannot exceed it on any roll - so this is an exact zero rather than + /// a small number, and it is the boundary the whole feature is measured + /// from. + #[test] + fn a_volley_that_cannot_breach_rolls_no_criticals() { + let (shooter, target) = pair(); + let shots = vec![shot(2, 2, 4, 5.0, 2)]; + assert_eq!(criticals(&shooter, &target, &shots), 0.0); + assert_eq!( + ExpectedCriticals::measure(&Projected { + shooter: &shooter, + expected_damage: 0.0, + p_kill: 0.0, + p_mission_kill: 0.0, + value_destroyed: 0.0, + expected_criticals: 0.0, + at_stake: 0.0, + volley_heat: 0.0, + move_heat: 0, + }) + .get(), + 0.0 + ); + } + + /// Ten points in one location against ten spread over the target. + /// + /// The sentence `ev.rs` argues from, measured. Same expected damage, and + /// only the slug puts enough of it in one place to get past a plate: the + /// missiles arrive a point at a time and the armour eats every one of them. + #[test] + fn ten_points_in_one_place_beats_ten_spread_thin() { + let mut shooter = mek(1); + shooter.friendly = true; + shooter.weapons = vec![gun(1, 10.0, 1), rack(2, 10, 4, 16)]; + let target = mek(2); + + let slug = vec![shot(1, 2, 4, 10.0, 1)]; + let missiles = vec![Shot { + damage_per_packet: 1.0, + ..shot(2, 2, 4, 6.0, 4) + }]; + let punch = criticals(&shooter, &target, &slug); + let spray = criticals(&shooter, &target, &missiles); + assert!(punch > spray * 5.0, "{punch} against {spray}"); + } + + /// The same volley is worth more against a machine already open. + /// + /// `p_breach` reads 1.00 against both of these once the armour is gone and + /// stops there. This is the column that says what came through the hole. + #[test] + fn an_open_target_is_worth_more_criticals_than_a_whole_one() { + let (shooter, whole) = pair(); + let open = stripped(); + let shots = vec![shot(1, 2, 4, 10.0, 3)]; + let behind_armour = criticals(&shooter, &whole, &shots); + let through_it = criticals(&shooter, &open, &shots); + assert!( + through_it > behind_armour * 2.0, + "{through_it} against {behind_armour}" + ); + } + + /// The count is internal damage over the packet, times the table. + /// + /// A single-gun volley has one packet size, so the approximation the doc + /// comment names does not apply and the three factors can be multiplied out + /// by hand. + #[test] + fn the_count_is_internal_damage_divided_by_one_packet() { + let (shooter, _) = pair(); + let open = stripped(); + let shots = vec![shot(1, 2, 4, 10.0, 3)]; + let mut cache = EvCache::new(); + let fired = Fired::new(&mut cache, &shooter, &shots, &open, Side::Front); + + // One 10-point gun, so every packet is 10 points. + assert!((fired.damage.mean_packet() - 10.0).abs() < 1e-4); + let by_hand = + fired.locations.internal_total() / 10.0 * crate::hitloc::CRITICALS_PER_INTERNAL_HIT; + let measured = criticals(&shooter, &open, &shots); + assert!((measured - by_hand).abs() < 1e-5, "{measured} != {by_hand}"); + } + #[test] fn value_destroyed_prefers_a_target_with_something_in_it() { let (shooter, target) = pair(); diff --git a/crates/sds-core/src/features/mod.rs b/crates/sds-core/src/features/mod.rs index 295ddf1..2290375 100644 --- a/crates/sds-core/src/features/mod.rs +++ b/crates/sds-core/src/features/mod.rs @@ -834,6 +834,7 @@ macro_rules! for_each_feature { $apply!($crate::features::firing::Overkill); $apply!($crate::features::firing::PBreach); $apply!($crate::features::firing::ValueDestroyed); + $apply!($crate::features::firing::ExpectedCriticals); $apply!($crate::features::firing::DamageLead); $apply!($crate::features::target::TargetHealth); $apply!($crate::features::target::TargetBreach); diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index ae69af7..551001c 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -29,10 +29,11 @@ //! defence, and one operator over `N` cannot say so. //! //! **The outputs stay apart.** [`Outcome`] carries expected damage, `p_kill`, -//! `p_mission_kill`, expected breaches, `value_destroyed` and the firing heat -//! at one enemy position; [`Prospect`] carries the same six through both -//! aggregations, because a feature that wants `p_kill` collapsed over `M` must -//! not have to re-derive it from a scalar that already threw it away. +//! `p_mission_kill`, expected breaches, `value_destroyed`, expected criticals +//! and the firing heat at one enemy position; [`Prospect`] carries the same +//! seven through both aggregations, because a feature that wants `p_kill` +//! collapsed over `M` must not have to re-derive it from a scalar that already +//! threw it away. //! //! **Two types, because they are two things.** An [`Outcome`] is one volley at //! one member of `M` and nothing about it is averaged. A [`Prospect`] is a set @@ -136,7 +137,7 @@ pub fn robust_mean(values: &[f32], p: f32, sense: Sense) -> f32 { /// standing in a particular hex, and it is what [`Exchange`] holds in each /// direction. /// -/// Six numbers, kept apart and never folded into a scalar. Five are what the +/// Seven numbers, kept apart and never folded into a scalar. Six are what the /// volley did to the target; [`Self::heat`] is what it cost the firer, which /// is an outcome of the shot in the same sense the damage is. #[derive(Debug, Clone, Copy, Default, PartialEq)] @@ -146,6 +147,9 @@ pub struct Outcome { pub p_mission_kill: f32, pub expected_breaches: f32, pub value_destroyed: f32, + /// Expected criticals rolled, from [`VolleyOutcome::expected_criticals`]. + /// A count, so it collapses and adds the way the damage figures do. + pub expected_criticals: f32, /// Heat this volley incurs, in heat points, from [`VolleyOutcome::heat`]. /// /// An integer, because one volley's heat is a count of heat points and not @@ -176,6 +180,7 @@ impl Outcome { p_mission_kill: outcome.p_mission_kill, expected_breaches: outcome.expected_breaches, value_destroyed: outcome.value_destroyed, + expected_criticals: outcome.expected_criticals, heat: outcome.heat, } } @@ -198,7 +203,8 @@ pub struct Prospect { pub p_mission_kill: f32, pub expected_breaches: f32, pub value_destroyed: f32, - /// Firing heat over `M`, collapsed the same way the other five are. + pub expected_criticals: f32, + /// Firing heat over `M`, collapsed the same way the other six are. /// /// **It survives the collapse deliberately.** We do not choose `M`; we /// fire from `L` at wherever the enemy actually ends up, so "what this @@ -240,6 +246,7 @@ impl Prospect { p_mission_kill: pull(|o| o.p_mission_kill), expected_breaches: pull(|o| o.expected_breaches), value_destroyed: pull(|o| o.value_destroyed), + expected_criticals: pull(|o| o.expected_criticals), heat: pull(|o| o.heat as f32), } } @@ -251,6 +258,7 @@ impl Prospect { self.p_mission_kill += other.p_mission_kill; self.expected_breaches += other.expected_breaches; self.value_destroyed += other.value_destroyed; + self.expected_criticals += other.expected_criticals; self.heat += other.heat; } @@ -1591,6 +1599,7 @@ pub fn propose( p_kill: score.offence.p_kill, p_mission_kill: score.offence.p_mission_kill, value_destroyed: score.offence.value_destroyed, + expected_criticals: score.offence.expected_criticals, at_stake, volley_heat: score.offence.heat, // What reaching this stand costs, which the wire cannot know @@ -1968,6 +1977,7 @@ mod tests { p_mission_kill: 0.4, expected_breaches: 2.0, value_destroyed: 30.0, + expected_criticals: 1.5, heat: 4, }, Outcome { @@ -1976,6 +1986,7 @@ mod tests { p_mission_kill: 0.2, expected_breaches: 1.0, value_destroyed: 10.0, + expected_criticals: 0.5, heat: 12, }, ]; @@ -1988,6 +1999,7 @@ mod tests { assert!((worst.p_mission_kill - 0.2).abs() < 1e-5); assert!((worst.expected_breaches - 1.0).abs() < 1e-5); assert!((worst.value_destroyed - 10.0).abs() < 1e-5); + assert!((worst.expected_criticals - 0.5).abs() < 1e-5); // Heat is one of the fields and takes its own minimum with the rest. // On a gain that is the cheap end, which the field's note says out // loud: it is the heat that comes with the damage this exponent @@ -1997,6 +2009,7 @@ mod tests { let mean = Prospect::collapse(&outcomes, 1.0, Sense::Gain); assert!((mean.expected_damage - 15.0).abs() < 1e-4); assert!((mean.p_mission_kill - 0.3).abs() < 1e-5); + assert!((mean.expected_criticals - 1.0).abs() < 1e-5); assert!((mean.heat - 8.0).abs() < 1e-4); // And it is monotone in p like everything else, between those two. diff --git a/crates/sds-core/src/volley.rs b/crates/sds-core/src/volley.rs index e40a8e9..c9989d6 100644 --- a/crates/sds-core/src/volley.rs +++ b/crates/sds-core/src/volley.rs @@ -514,8 +514,8 @@ pub struct VolleyKey { /// What a volley from one state at one target is worth, and what it costs. /// -/// The five damage fields are measured off the same [`LocationDamage`] for the -/// same chosen twist, so they describe one volley rather than five. +/// The six damage fields are measured off the same [`LocationDamage`] for the +/// same chosen twist, so they describe one volley rather than six. /// [`Self::heat`] is the cost of that same volley, counted over the weapons /// that fire rather than off the damage - and attached after the memo, not /// through it. @@ -553,6 +553,10 @@ pub struct VolleyOutcome { /// `sum over locations of P(destroyed) x value`: how much of the target's /// ability to fight this is expected to stop. pub value_destroyed: f32, + /// Expected number of criticals rolled, from the damage that gets past + /// armour. A count, and the exclusions it makes are on + /// [`LocationDamage::expected_criticals`]. + pub expected_criticals: f32, } impl VolleyOutcome { @@ -570,6 +574,7 @@ impl VolleyOutcome { p_mission_kill: 0.0, expected_breaches: 0.0, value_destroyed: 0.0, + expected_criticals: 0.0, } } @@ -953,7 +958,7 @@ pub fn gather( /// The estimate, as a function of the key and nothing else. /// -/// The five damage outputs come off one [`LocationDamage`] per twist, and the +/// The six damage outputs come off one [`LocationDamage`] per twist, and the /// twist with the best [`VolleyOutcome::objective`] is returned whole. /// [`VolleyOutcome::heat`] is left at zero here: heat is not in the key, and /// [`best_volley_keyed`] fills it in from [`Gathered`] afterwards. @@ -985,6 +990,14 @@ pub fn solve(ev: &mut EvCache, key: &VolleyKey) -> VolleyOutcome { // in a fixed order. expected_breaches: (0..hitloc::COUNT).map(|at| locations.breached(at)).sum(), value_destroyed: locations.expected_value_destroyed(), + // The packet size comes off the whole-target allocation rather + // than off `locations`: a per-location packet count is how many + // *arrived there*, and dividing internal damage by that would ask + // a different question in each location. The mean packet is the + // same figure whichever location it is asked about - see + // `LocationDamage::expected_criticals`. + expected_criticals: locations + .expected_criticals(ev.allocation(&option.shots).mean_packet()), }; if best.as_ref().is_none_or(|held| outcome.better_than(held)) { best = Some(outcome); diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b302a30..5dfb29f 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -10,7 +10,7 @@ Every figure is measured by the feature's own `measure`, through the same code the bot runs. Nothing on this page is a transcription, and `crates/sds-core/tests/vignettes.rs` fails if any of it has gone stale. -All 51 of them, because a feature cannot exist without one - see `crates/sds-core/examples/vignettes/mod.rs`. +All 52 of them, because a feature cannot exist without one - see `crates/sds-core/examples/vignettes/mod.rs`. ## ammo_spent @@ -202,6 +202,16 @@ The centroid is rounded to a hex before the distance is taken, because hex dista The nearest one only. A hex halfway between two enemies and a hex the same distance from one with nobody else on the board read the same number, which is why `range_spread` and `enemy_centroid` exist beside it. The denominator is the board's own longest hex distance - the two far corners of the 16x17 map set are 24 hexes apart, not 17 - so the far corner is 1.00 and nothing is over it. +## expected_criticals + +`bounded`. How many critical hits we expect this volley to roll on the target. + +**The figure shows** one machine of ours standing still with a Gauss rifle and an SRM-6, and a column of hexes an enemy could be standing in, with the board tinted into the regions where both guns are doing one thing throughout. + +![expected_criticals](features/expected_criticals.svg) + +Criticals, not damage. The count is what gets *past* the armour divided by what one packet carries, and it is the column `p_breach` cannot give: `p_breach` says a location was opened and stops there, this says what came through the hole. The shape follows `expected_damage` - best where both guns are in bracket, worse inside the Gauss rifle's minimum range - and the spread over it is far steeper, 0.03 at the long end against 0.23 at the best hex where expected damage only goes from 0.11 to 0.42. A location has to be opened before anything can come through it, so the second gun coming into bracket is aimed at armour the first has already been through. This target is a 55-tonner well into a fight; one still behind whole armour reads a flat zero at every range. + ## expected_damage `bounded`. The share of everything our guns could put out that this volley lands. diff --git a/docs/TACTICS.md b/docs/TACTICS.md index 9c80ba6..d43bc30 100644 --- a/docs/TACTICS.md +++ b/docs/TACTICS.md @@ -87,7 +87,7 @@ The bars below are that stand-in, drawn so the gap is legible rather than hidden ![advance](tactics/advance.svg) -Moves 4 of the 51 features in the basis: `enemy_centroid`, `enemy_nearest`, `incoming_damage`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 4 of the 52 features in the basis: `enemy_centroid`, `enemy_nearest`, `incoming_damage`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. ## bombard @@ -101,7 +101,7 @@ The bars below are that stand-in, drawn so the gap is legible rather than hidden ![bombard](tactics/bombard.svg) -Moves 11 of the 51 features in the basis: `cover_quality`, `elevation_gain`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `p_kill`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 11 of the 52 features in the basis: `cover_quality`, `elevation_gain`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `p_kill`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. ## break @@ -113,7 +113,7 @@ Get out of their line entirely, and give up this turn's shot for it. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 7 of the 51 features in the basis: `cover_distance`, `cover_quality`, `expected_damage`, `los_in`, `los_out`, `p_kill`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 7 of the 52 features in the basis: `cover_distance`, `cover_quality`, `expected_damage`, `los_in`, `los_out`, `p_kill`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `harass`, 0.21 of the time on the mirrored corpus and 0.22 on the asymmetric one - a new direction. @@ -129,7 +129,7 @@ The bars below are that stand-in, drawn so the gap is legible rather than hidden ![control](tactics/control.svg) -Moves 9 of the 51 features in the basis: `cohesion`, `cover_quality`, `elevation_gain`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 9 of the 52 features in the basis: `cohesion`, `cover_quality`, `elevation_gain`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. ## engage @@ -141,7 +141,7 @@ Fight where our guns work, and take the exchange we are offered. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -The baseline itself, so the other figures have something to be differences from. It gives a non-zero weight to 36 of the 51 features in the basis. The rest are not oversights: a firing decision measures none of the movement features and a movement decision none of the firing ones, so a weight for a feature that decision never took would contribute nothing anyway. `heat_incurred` is the one deliberate zero among the features it does name - see the comment on `Weights::hand_authored` for why the hand set declines to guess it. +The baseline itself, so the other figures have something to be differences from. It gives a non-zero weight to 36 of the 52 features in the basis. The rest are not oversights: a firing decision measures none of the movement features and a movement decision none of the firing ones, so a weight for a feature that decision never took would contribute nothing anyway. `heat_incurred` is the one deliberate zero among the features it does name - see the comment on `Weights::hand_authored` for why the hand set declines to guess it. Agrees most with `flank`, 0.66 of the time on the mirrored corpus and 0.68 on the asymmetric one - partly new. @@ -155,7 +155,7 @@ Take ground that is hard to shoot us on, and hold it. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 8 of the 51 features in the basis: `cover_quality`, `elevation_gain`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 8 of the 52 features in the basis: `cover_quality`, `elevation_gain`, `incoming_damage`, `level_terrain`, `level_tmm`, `los_in`, `los_out`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `engage`, 0.38 of the time on the mirrored corpus and 0.41 on the asymmetric one - a new direction. @@ -169,7 +169,7 @@ Take the arc rather than the range: get behind them. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 5 of the 51 features in the basis: `cohesion`, `enemy_centroid`, `enemy_nearest`, `range_band_fit`, `rear_arc_gain`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 5 of the 52 features in the basis: `cohesion`, `enemy_centroid`, `enemy_nearest`, `range_band_fit`, `rear_arc_gain`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `engage`, 0.66 of the time on the mirrored corpus and 0.68 on the asymmetric one - partly new. @@ -183,7 +183,7 @@ Shoot from where we can still leave, and never be where they want us. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 6 of the 51 features in the basis: `cover_distance`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `los_in`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 6 of the 52 features in the basis: `cover_distance`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `los_in`, `percent_options`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `engage`, 0.45 of the time on the mirrored corpus and 0.43 on the asymmetric one - partly new. @@ -197,7 +197,7 @@ Get back together before doing anything else. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 5 of the 51 features in the basis: `cohesion`, `enemy_nearest`, `expected_damage`, `friend_support`, `incoming_damage`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 5 of the 52 features in the basis: `cohesion`, `enemy_nearest`, `expected_damage`, `friend_support`, `incoming_damage`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `engage`, 0.44 of the time on the mirrored corpus and 0.47 on the asymmetric one - partly new. @@ -213,7 +213,7 @@ The bars below are that stand-in, drawn so the gap is legible rather than hidden ![reposition](tactics/reposition.svg) -Moves 5 of the 51 features in the basis: `expected_damage`, `incoming_damage`, `p_kill`, `percent_options`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 5 of the 52 features in the basis: `expected_damage`, `incoming_damage`, `p_kill`, `percent_options`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. ## screen @@ -227,7 +227,7 @@ The bars below are that stand-in, drawn so the gap is legible rather than hidden ![screen](tactics/screen.svg) -Moves 4 of the 51 features in the basis: `cohesion`, `friend_support`, `incoming_damage`, `los_in`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 4 of the 52 features in the basis: `cohesion`, `friend_support`, `incoming_damage`, `los_in`. Everything not drawn is priced exactly as `Engage` prices it. ## withdraw @@ -239,6 +239,6 @@ Get off the board alive. The same board and the same eight machines under every implemented tactic, so a difference between two figures is a difference between two weightings and nothing else. Each arrow is an argmax over that unit's real reachable set, scored through the builder the bot's own menu calls - not a drawing of where a tactic ought to go. Their lance does not move, and is scored where it stands rather than over everywhere it could reach, so the picture and the arithmetic agree about what ours was deciding against. Two arrows ending in one hex are not a bug either, and are worth looking at: each unit is scored alone, so nothing here stops four machines wanting the same ground. Reconciling that is the force layer's job, and this is what it has to work with. -Moves 9 of the 51 features in the basis: `cohesion`, `edge_distance`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `level_tmm`, `los_in`, `p_kill`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. +Moves 9 of the 52 features in the basis: `cohesion`, `edge_distance`, `enemy_nearest`, `expected_damage`, `incoming_damage`, `level_tmm`, `los_in`, `p_kill`, `range_band_fit`. Everything not drawn is priced exactly as `Engage` prices it. Agrees most with `break`, 0.16 of the time on the mirrored corpus and 0.17 on the asymmetric one - a new direction. diff --git a/docs/features/expected_criticals.svg b/docs/features/expected_criticals.svg new file mode 100644 index 0000000..caae827 --- /dev/null +++ b/docs/features/expected_criticals.svg @@ -0,0 +1,98 @@ +1 · Gauss Rifle short, minimum +2, SRM-6 short1 · Gauss Rifle short, minimum +2, SRM-6 short1 · Gauss Rifle short, minimum +2, SRM-6 short1 · Gauss Rifle short, minimum +2, SRM-6 short12 · Gauss Rifle short, minimum +1, SRM-6 short2 · Gauss Rifle short, minimum +1, SRM-6 short2 · Gauss Rifle short, minimum +1, SRM-6 short2 · Gauss Rifle short, minimum +1, SRM-6 short2 · Gauss Rifle short, minimum +1, SRM-6 short2 · Gauss Rifle short, minimum +1, SRM-6 short23 · Gauss Rifle short, SRM-6 short3 · Gauss Rifle short, SRM-6 short3 · Gauss Rifle short, SRM-6 short3 · Gauss Rifle short, SRM-6 short3 · Gauss Rifle short, SRM-6 short3 · Gauss Rifle short, SRM-6 short3 · Gauss Rifle short, SRM-6 short3 · Gauss Rifle short, SRM-6 short3 · Gauss Rifle short, SRM-6 short3 · Gauss Rifle short, SRM-6 short34–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–6 · Gauss Rifle short, SRM-6 medium +24–67 · Gauss Rifle short, SRM-6 long +47 · Gauss Rifle short, SRM-6 long +47 · Gauss Rifle short, SRM-6 long +47 · Gauss Rifle short, SRM-6 long +47 · Gauss Rifle short, SRM-6 long +47 · Gauss Rifle short, SRM-6 long +47 · Gauss Rifle short, SRM-6 long +47 · Gauss Rifle short, SRM-6 long +478–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–9 · Gauss Rifle medium +2, SRM-6 long +48–910–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14 · Gauss Rifle medium +2, SRM-6 out of range10–14(4, 1) a target here is 11 away, 1 gun(s) in bracket0.03(4, 2) a target here is 10 away, 1 gun(s) in bracket0.03(4, 3) a target here is 9 away, 2 gun(s) in bracket0.04(4, 4) a target here is 8 away, 2 gun(s) in bracket0.04(4, 5) a target here is 7 away, 2 gun(s) in bracket0.08(4, 6) a target here is 6 away, 2 gun(s) in bracket0.14(4, 7) a target here is 5 away, 2 gun(s) in bracket0.14(4, 8) a target here is 4 away, 2 gun(s) in bracket0.14(4, 9) a target here is 3 away, 2 gun(s) in bracket0.23(4, 10) a target here is 2 away, 2 gun(s) in bracket0.19(4, 11) a target here is 1 away, 2 gun(s) in bracket0.15what is being shot atwhat is being shot atwhat is being shot atwhat is being shot atwhat is being shot atwhat is being shot atwhat is being shot atwhat is being shot atwhat is being shot atwhat is being shot atwhat is being shot atU10.001.00expected_criticalsa hex no candidate reaches, so nothing measured ittarget at 1 · Gauss Rifle short, minimum +2, SRM-6 shorttarget at 2 · Gauss Rifle short, minimum +1, SRM-6 shorttarget at 3 · Gauss Rifle short, SRM-6 shorttarget at 4–6 · Gauss Rifle short, SRM-6 medium +2target at 7 · Gauss Rifle short, SRM-6 long +4target at 8–9 · Gauss Rifle medium +2, SRM-6 long +4target at 10–14 · Gauss Rifle medium +2, SRM-6 out of range diff --git a/sds/train.py b/sds/train.py index ffad2f0..e023617 100644 --- a/sds/train.py +++ b/sds/train.py @@ -109,6 +109,14 @@ EXPECTED_SIGNS: dict[str, int] = { "target_breach": +1, "p_breach": +1, "value_destroyed": +1, + # Criticals rolled on the target, as a count. A critical breaks something + # the target fights with, and it is the way a match ends that a damage + # total cannot see - an ammunition explosion in round three is criticals + # and nothing else. A label that rewards fighting well cannot want fewer. + # The size is left entirely to the fit: the count is deliberately not + # weighted by what the critical hit, so there is nothing here to check but + # the direction. + "expected_criticals": +1, # `P(some location takes more than it can absorb)`. It was a share of the # volley's damage and is now the probability its name always claimed; the # sign is unchanged and follows from the new sentence just as plainly, since