From eaf0b8033431e2aa99016a091b581f345a6b620b Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 17:59:29 -0400 Subject: [PATCH] feat(candidates)!: emit the Pareto frontier instead of top K per axis Replaces the two top-K lists with the non-dominated set over one damage column per enemy against damage taken, and turns it into Proposals in the existing feature basis. Top K by least damage taken selected the hex nothing could see, by construction: its picks dealt 0.00 and at p = 1 ran for the map edge. A frontier fixes that with no exchange rate, and one gain axis per enemy keeps the stand that is the only line on a given target. The two lists stay as instruments, the bound spreads its picks along the frontier rather than truncating, and both lengths are logged. Nothing in sds-bot consumes any of it. --- crates/sds-bot/src/unit.rs | 5 + crates/sds-core/examples/heatmap.rs | 3 + crates/sds-core/examples/stands.rs | 250 +++++++- crates/sds-core/src/heatmap.rs | 2 + crates/sds-core/src/plan.rs | 23 + crates/sds-core/src/stands.rs | 730 ++++++++++++++++++++++- crates/sds-core/tests/stands_frontier.rs | 448 ++++++++++++++ 7 files changed, 1418 insertions(+), 43 deletions(-) create mode 100644 crates/sds-core/tests/stands_frontier.rs diff --git a/crates/sds-bot/src/unit.rs b/crates/sds-bot/src/unit.rs index 1350e9c..b5682f2 100644 --- a/crates/sds-bot/src/unit.rs +++ b/crates/sds-bot/src/unit.rs @@ -665,6 +665,11 @@ fn measure( end: Some(offer.end), features, damage_dealt: offer.damage_dealt, + // The four-verb generator projects a volley at the nearest enemy + // and at nothing else, so it has no per-target column to report. + // Empty, which `skip_serializing_if` keeps out of the log entirely: + // this path's records are byte for byte what they were. + damage_by_target: std::collections::BTreeMap::new(), damage_taken: offer.damage_taken, risk: 0.0, notes: offer.notes.clone(), diff --git a/crates/sds-core/examples/heatmap.rs b/crates/sds-core/examples/heatmap.rs index 7bcadaf..fc2e4e8 100644 --- a/crates/sds-core/examples/heatmap.rs +++ b/crates/sds-core/examples/heatmap.rs @@ -762,6 +762,9 @@ fn main() { &Params { exponent: regime.exponent, top_k: TOP_K, + // The heatmap draws the two lists, not the + // frontier. The default bound is fine here. + ..Params::default() }, ) }) diff --git a/crates/sds-core/examples/stands.rs b/crates/sds-core/examples/stands.rs index 826b452..ecc0032 100644 --- a/crates/sds-core/examples/stands.rs +++ b/crates/sds-core/examples/stands.rs @@ -2,7 +2,18 @@ //! //! Every `(hex, facing)` a Mek can reach, scored against three enemies that //! have not moved yet, printed as a board: what we deal from a hex, what we take -//! at it, and which stands make the top *K* of each list. +//! at it, and which stands are on the Pareto frontier through the two. +//! +//! The frontier is the proposal set. The two top-*K* lists are printed beside +//! it as instruments, and the defence list is worth reading: it is the one that +//! picks stands with no line to anything, which is what a frontier fixes. +//! +//! The last section is the point of the whole file. The frontier is turned into +//! `Proposal`s in the existing feature basis and scored under several stances, +//! so the same set of hexes is chosen from twice by two doctrines. What varies +//! down the table is *what we are trying to do*; what varies across the panels +//! above is *what we think the enemy will do*. They are different axes and the +//! table keeps them apart. //! //! cargo run --release -p sds-core --example stands //! @@ -21,15 +32,22 @@ use std::collections::{BTreeMap, BTreeSet}; +use sds_core::features::{firing, positional, Feature, Weights}; use sds_core::heatmap::HexMap; use sds_core::pathfind::MoveBoard; -use sds_core::stands::{prune_selectivity, score_stands, Params, Ranking}; +use sds_core::stance::{Intent, Stance}; +use sds_core::stands::{propose, prune_selectivity, score_stands, Params, Ranking}; use sds_core::volley::VolleyCache; use sds_core::wire::Coord; mod common; use common::{Scene, ENEMIES, EXPONENTS, OUR_MP, START, THEIR_MP, TOP_K}; +/// The most stands the frontier may emit. Below the frontier length on every +/// board here, so the thinning is doing something visible rather than being +/// theoretical. +const FRONTIER_MAX: usize = 12; + fn glyph(board: &MoveBoard, at: Coord) -> char { let hex = board.hex(at); if hex.impassable { @@ -75,7 +93,7 @@ fn by_hex(ranking: &Ranking) -> BTreeMap<(i32, i32), (f32, f32)> { /// Two text lines per board row, because odd columns sit half a hex lower. /// /// A cell is `terrain deal take mark`: the glyph, our damage out and their -/// damage in on the 0-9 scale, and `*` on a hex holding one of the top K. +/// damage in on the 0-9 scale, and `*` on a hex holding a frontier stand. struct Picture<'a> { board: &'a MoveBoard, width: i32, @@ -90,6 +108,7 @@ struct Picture<'a> { fn render( picture: &Picture<'_>, values: &BTreeMap<(i32, i32), (f32, f32)>, + frontier: &BTreeSet<(i32, i32)>, offence: &BTreeSet<(i32, i32)>, defence: &BTreeSet<(i32, i32)>, ) -> String { @@ -122,13 +141,17 @@ fn render( line.push( match ( enemies.contains(&key), + frontier.contains(&key), offence.contains(&key), defence.contains(&key), ) { - (true, _, _) => 'E', - (_, true, true) => 'b', - (_, true, false) => 'o', - (_, false, true) => 'd', + (true, _, _, _) => 'E', + // The proposal set wins the cell. The two lists are + // instruments and only get the cells the frontier left. + (_, true, _, _) => '*', + (_, _, true, true) => 'b', + (_, _, true, false) => 'o', + (_, _, false, true) => 'd', _ => ' ', }, ); @@ -141,6 +164,100 @@ fn render( out } +/// Add `delta` to one named weight, keeping whatever was already there. +macro_rules! bump { + ($weights:expr, $feature:ty, $delta:expr) => {{ + let now = $weights.get(<$feature as Feature>::NAME); + $weights.set::<$feature>(now + $delta); + }}; +} + +/// Doctrine, and a stand-in for something the library does not have. +/// +/// There is no bridge from a [`Stance`] to a [`Weights`] anywhere in the tree. +/// `ForceThinker::command` scores every proposal with one weight set, and +/// `sds-bot/src/unit.rs` takes a `stance` argument and then writes +/// `let _ = stance;`. Choosing along a frontier needs such a bridge, so this +/// example writes one down where a reader can see that it is a demonstration. +/// +/// It is deliberately not in `sds-core`. What a stance emphasises is a tactical +/// opinion, it is exactly the kind of table that should be argued over before +/// it ships, and nobody has argued over this one. Every weight it touches is +/// already in the hand-authored set; nothing here invents a feature. +fn under(stance: &Stance) -> Weights { + let mut weights = Weights::hand_authored(); + for intent in Intent::ALL { + let k = stance.weight(intent); + if k <= 0.0 { + continue; + } + match intent { + // Get into our band and accept being shot on the way. + Intent::Close => { + bump!(weights, positional::RangeBandFit, 5.0 * k); + bump!(weights, positional::LosOut, 2.0 * k); + bump!(weights, positional::Exposure, 3.0 * k); + } + // Our guns working, theirs not, and no interest in walking closer. + Intent::Hold => { + bump!(weights, positional::RangeBandFit, 4.0 * k); + bump!(weights, positional::CoverQuality, 1.0 * k); + bump!(weights, positional::Exposure, -k); + } + Intent::Flank => { + bump!(weights, positional::RearArcGain, 6.0 * k); + bump!(weights, positional::LosOut, 2.0 * k); + } + Intent::BreakLos => { + bump!(weights, positional::LosIn, -4.0 * k); + bump!(weights, positional::CoverQuality, 4.0 * k); + bump!(weights, positional::LosOut, -2.0 * k); + } + Intent::Focus => { + bump!(weights, firing::PKill, 4.0 * k); + bump!(weights, firing::ExpectedDamage, 4.0 * k); + } + Intent::Withdraw => { + bump!(weights, positional::Exposure, -5.0 * k); + bump!(weights, positional::LosIn, -4.0 * k); + bump!(weights, positional::CoverQuality, 3.0 * k); + bump!(weights, positional::RangeBandFit, -4.0 * k); + } + Intent::Screen => { + bump!(weights, positional::Cohesion, 4.0 * k); + bump!(weights, positional::Exposure, 1.0 * k); + } + } + } + weights +} + +/// The stances the frontier is chosen from under. +/// +/// Blends rather than pure intents, because a blend is the normal case: a lance +/// that only ever wanted one thing would not need a weight vector to say so. +fn doctrines() -> Vec<(&'static str, Stance)> { + vec![ + ("no opinion", Stance::default()), + ( + "close", + Stance::of(&[(Intent::Close, 0.7), (Intent::Focus, 0.3)]), + ), + ( + "hold", + Stance::of(&[(Intent::Hold, 0.7), (Intent::Focus, 0.3)]), + ), + ( + "flank", + Stance::of(&[(Intent::Flank, 0.6), (Intent::Close, 0.4)]), + ), + ( + "withdraw", + Stance::of(&[(Intent::Withdraw, 0.7), (Intent::BreakLos, 0.3)]), + ), + ] +} + fn main() { let name = std::env::args() .nth(1) @@ -190,6 +307,7 @@ fn main() { let params = Params { exponent, top_k: TOP_K, + frontier_max: FRONTIER_MAX, }; rankings.push(( label, @@ -219,7 +337,7 @@ fn main() { }; println!( - "cell: terrain, damage dealt, damage taken, then o / d / b for a top {TOP_K} stand by\n offence / defence / both, or E for an enemy start" + "cell: terrain, damage dealt, damage taken, then * for a stand on the frontier,\n o / d / b for a top {TOP_K} stand by offence / defence / both, E for an enemy start" ); println!( "digits are 0-9 against one scale for all three boards: 9 = {deal_full:.1} dealt, 9 = {take_full:.1} taken" @@ -251,23 +369,53 @@ fn main() { }; let offence_hexes = hexes(&mut ranking.best_offence()); let defence_hexes = hexes(&mut ranking.best_defence()); + let frontier_hexes = hexes(&mut ranking.on_frontier()); println!(); println!("=== {label} ==="); println!( "{}", - render(&picture, &values, &offence_hexes, &defence_hexes) + render( + &picture, + &values, + &frontier_hexes, + &offence_hexes, + &defence_hexes + ) + ); + + // The bound, said out loud. `plan/candidates.md` asks for a bounded + // coverage to be logged rather than truncated quietly. + println!( + " frontier: {} non-dominated stands over {} hexes, {} emitted (bound {FRONTIER_MAX})", + ranking.stats.frontier, + frontier_hexes.len(), + ranking.stats.emitted + ); + // What the extra axes buy. The frontier is taken over one damage column + // per enemy plus damage taken; the two-axis rule compared the best + // target only, and a stand that is the sole line on one enemy is + // dominated on that. + println!( + " best-target-only would keep {}; the per-target axes rescue {} of those dropped", + ranking.stats.summary_frontier, ranking.stats.rescued ); for (name, list) in [ + ("the frontier - safest first", ranking.frontier.clone()), ("offence - most dealt", ranking.by_offence.clone()), ("defence - least taken", ranking.by_defence.clone()), ] { - println!(" top {TOP_K} by {name}:"); + println!(" {name}:"); for at in &list { let score = &ranking.scored[*at]; + let per_target: Vec = score + .per_target + .iter() + .map(|(id, value)| format!("{id}:{:.1}", value.expected_damage)) + .collect(); println!( " {} deal {:>6.2} take {:>6.2} p_kill {:.4} \ - mission {:.4} breaches {:.2} value {:>6.2} target {}", + mission {:.4} breaches {:.2} value {:>6.2} target {} [{}]", describe(score), score.offence.expected_damage, score.defence.expected_damage, @@ -279,15 +427,29 @@ fn main() { .best_target .map(|id| id.to_string()) .unwrap_or_else(|| "none".to_string()), + per_target.join(" "), ); } } let offence_cover = ranking.best_offence().filter(|s| in_cover(s)).count(); let defence_cover = ranking.best_defence().filter(|s| in_cover(s)).count(); + let frontier_cover = ranking.on_frontier().filter(|s| in_cover(s)).count(); + let fighting = ranking + .on_frontier() + .filter(|s| s.offence.expected_damage > 0.0) + .count(); println!( - " in woods or beside them: {offence_cover}/{TOP_K} of the offence list, \ - {defence_cover}/{TOP_K} of the defence list" + " in woods or beside them: {frontier_cover}/{} of the frontier, \ + {offence_cover}/{TOP_K} of the offence list, \ + {defence_cover}/{TOP_K} of the defence list", + ranking.frontier.len() + ); + // The regression the frontier exists to prevent, printed rather than + // assumed: a proposal set where nothing can shoot is the old answer. + println!( + " frontier stands that can shoot something: {fighting}/{}", + ranking.frontier.len() ); verdict.push(( (*label).to_string(), @@ -335,6 +497,68 @@ fn main() { if cover_rises_as_p_falls { "yes" } else { "NO" } ); + // The same frontier, chosen from by different doctrines. + // + // Both axes at full cross-product: five stances down, three beliefs about + // the enemy across. Fifteen lines is readable and merging the two axes + // would not be - a stance is what we are trying to do and the exponent is + // what we think they will do, and neither stands in for the other. + println!(); + println!("the frontier, chosen from under a stance"); + println!(" proposals carry the positional group from `Posture` and four of the firing group"); + println!(" from the aggregate; the weights are `hand_authored` tilted by the stance"); + println!(); + println!( + " {:<20} {:<11} {:>12} {:>8} {:>8} {:>8} fights", + "exponent", "stance", "chosen", "deal", "take", "value" + ); + for (label, ranking) in &rankings { + let proposals = propose(&scene.los, &mover, ranking, &foes, &[]); + + // How much room a weight has to work in. A feature that reads the same + // on every proposal cannot move an argmax however it is weighted, so + // the spread is printed next to the choices it did or did not make. + for feature in ["exposure", "range_band_fit", "cover_quality", "los_in"] { + let values: Vec = proposals + .iter() + .filter_map(|p| p.features.get(feature)) + .collect(); + let low = values.iter().copied().fold(f32::INFINITY, f32::min); + let high = values.iter().copied().fold(f32::NEG_INFINITY, f32::max); + println!(" {label} {feature:<15} spans {low:.2} to {high:.2}"); + } + + for (name, stance) in doctrines() { + let weights = under(&stance); + // Ties go to the earlier proposal, and the frontier is emitted in a + // fixed order, so nothing here depends on what finished first. + let mut best: Option<(f32, &sds_core::plan::Proposal)> = None; + for proposal in &proposals { + let value = proposal.value(&weights); + if best.is_none_or(|(held, _)| value > held) { + best = Some((value, proposal)); + } + } + let Some((value, proposal)) = best else { + println!(" {label:<20} {name:<11} {:>12}", "nothing"); + continue; + }; + let end = proposal.end.unwrap_or(START); + println!( + " {label:<20} {name:<11} {:>12} {:>8.2} {:>8.2} {:>8.2} {}", + format!("({:>2},{:>2})", end.x, end.y), + proposal.damage_dealt, + proposal.damage_taken, + value, + if proposal.damage_dealt > 0.0 { + "yes" + } else { + "NO" + }, + ); + } + } + println!(); let stats = cache.stats(); println!( diff --git a/crates/sds-core/src/heatmap.rs b/crates/sds-core/src/heatmap.rs index e7d2dbd..8bdaccd 100644 --- a/crates/sds-core/src/heatmap.rs +++ b/crates/sds-core/src/heatmap.rs @@ -373,12 +373,14 @@ mod tests { ..Aggregate::default() }, best_target: None, + per_target: Vec::new(), } } fn ranking(scored: Vec) -> Ranking { Ranking { scored, + frontier: Vec::new(), by_offence: Vec::new(), by_defence: Vec::new(), stats: StandStats::default(), diff --git a/crates/sds-core/src/plan.rs b/crates/sds-core/src/plan.rs index 2f782d1..b2a7b28 100644 --- a/crates/sds-core/src/plan.rs +++ b/crates/sds-core/src/plan.rs @@ -15,6 +15,8 @@ //! chose badly" and "no unit offered anything better" look identical in a log //! that only records the winner. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; use crate::features::{FeatureVector, Weights}; @@ -36,7 +38,25 @@ pub struct Proposal { /// force applies the same weights to every unit's menu. #[serde(default)] pub features: FeatureVector, + /// A summary, and lossy. The damage this proposal expects to do to the one + /// target it does most to; [`Self::damage_by_target`] is the real thing. + /// Kept because the existing path reports it and a reader wants one number. pub damage_dealt: f32, + /// What this proposal expects to do to **each** enemy, by unit id. + /// + /// A maximum over targets cannot express "the only position with a line on + /// unit 3", and that is the position a force concentrating fire needs. A + /// candidate generator that compares proposals on the scalar prunes it + /// before anybody sees it, so the map travels alongside. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub damage_by_target: BTreeMap, + /// What every enemy that can see this position expects to do to us, + /// summed. + /// + /// **Do not add these up across a force.** Every unit reports this as "if + /// they all shoot me", so a lance of eight credits the enemy with eight + /// times its firepower. It is a conditional, and turning it into a force + /// number is an allocation problem that belongs above this layer. pub damage_taken: f32, /// Chance of something going wrong on the way - a failed piloting roll, a /// fall, bogging down. Kept apart from damage because a force may be willing @@ -144,6 +164,7 @@ mod tests { end: None, features: measured(0.4, 0.1), damage_dealt: 12.0, + damage_by_target: BTreeMap::from([(7, 12.0)]), damage_taken: 3.0, risk: 0.5, notes: vec!["in woods".into()], @@ -202,6 +223,7 @@ mod tests { "label", "features", "damage_dealt", + "damage_by_target", "damage_taken", "risk", "notes", @@ -230,6 +252,7 @@ mod tests { end: None, features: measured(0.5, 0.25), damage_dealt: 20.0, + damage_by_target: BTreeMap::from([(9, 20.0)]), damage_taken: 0.0, risk: 0.0, notes: vec![], diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index 3fa816a..07f244e 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -26,13 +26,18 @@ //! offers its four verbs. This is the estimator and the instrument for reading //! it. +use crate::features::positional::Posture; +use crate::features::{firing, AmongCandidates, FeatureVector, Score}; use crate::hex::Stand; use crate::los::LosCache; use crate::pathfind::Reach; +use crate::plan::Proposal; use crate::volley::{ self, best_volley, twist_options, Firer, MountedWeapon, TargetState, VolleyCache, VolleyOutcome, }; -use crate::wire::{Coord, Unit}; +use crate::wire::{Action, Coord, Unit}; + +use std::collections::BTreeSet; /// The generalised power mean of non-negative values. /// @@ -288,8 +293,14 @@ pub struct Params { /// The power mean exponent over `M`. `f32::NEG_INFINITY` is minimax, `1.0` /// the arithmetic mean. pub exponent: f32, - /// How many stands to emit. + /// How long the two inspection lists are. Not the proposal set: see + /// [`Ranking`]. pub top_k: usize, + /// The most stands the frontier may emit. A frontier longer than this is + /// cut down by [`thin_frontier`], which spreads its picks along the curve + /// rather than taking one end, and both lengths are recorded in + /// [`StandStats`] so a bounded set is never a silent one. Zero is no bound. + pub frontier_max: usize, } impl Default for Params { @@ -297,6 +308,9 @@ impl Default for Params { Self { exponent: f32::NEG_INFINITY, top_k: 20, + // The cap every performance estimate here assumes: about twenty + // curated candidates per unit. + frontier_max: 20, } } } @@ -312,6 +326,16 @@ pub struct StandScore { pub defence: Aggregate, /// Which enemy `offence` describes, or `None` when nothing can be shot. pub best_target: Option, + /// What this stand deals to **each** enemy, by id, in the order the enemy + /// list was given in. + /// + /// The column the frontier is actually drawn on. `offence` is a max over + /// enemies and throws away the thing a force needs: a stand that deals less + /// to its own best target but is the only one with a line on the third + /// enemy is the hex that lets a lance concentrate, and a maximum cannot say + /// so. Kept keyed, so a caller asks about a target rather than about a + /// position in a list. + pub per_target: Vec<(i32, Aggregate)>, } /// What the sweep did, in counters. @@ -325,31 +349,59 @@ pub struct StandStats { pub stands: u64, /// `(L, M, N)` triples, counted once per direction of fire. pub exchanges: u64, + /// Stands on the Pareto frontier, before any bound is applied. + pub frontier: u64, + /// Stands actually emitted. Below `frontier` when the bound thinned it, + /// and printed next to it wherever a frontier is reported. + pub emitted: u64, + /// The frontier the old two-axis rule would have produced: best target + /// against damage taken. + pub summary_frontier: u64, + /// Stands on the per-target frontier that the two-axis one dropped. What + /// the extra axes are buying, as a number. + pub rescued: u64, } -/// The ranked stands and the counters from producing them. +/// The scored stands, the frontier drawn through them, and the counters. +/// +/// **The proposal set is the Pareto frontier over (deal high, take low).** +/// Stand `A` dominates stand `B` when `A` deals at least as much and takes no +/// more, with at least one of the two strict. [`Self::frontier`] is what +/// nothing dominates. /// -/// **Two lists, and no combined one.** Ranking on a single scalar means -/// choosing an exchange rate between damage dealt and damage taken, and that -/// rate is a tactical opinion: it is the thing [`crate::features`] and -/// `plan/strategies.md` exist to hold, and it changes with role, with damage -/// already taken, and with whether the force is winning. Collapsing to one -/// number here would bury a weight vector in the estimator and undo the reason -/// [`Aggregate`] carries five columns through both aggregations - a feature -/// chooses, and the last step must not choose for it. +/// It replaced the top `K` down each axis separately, which was degenerate on +/// the defence side by construction. "The `K` stands that take the least" is a +/// description of the hex nothing can see, so that list picked stands with no +/// line to any enemy at all, and at `p = 1` it ran for the map edge. Defence +/// barely discriminates either: on the `open` board the top eight spanned 47.1 +/// to 53.1 while damage dealt spanned 0.0 to 31.7, so ranking on it turned +/// noise into a decision. /// -/// It also gave a wrong answer. Ranking on `offence - defence` made the top -/// stands at a low exponent the ones with no exchange at all, because a hex -/// out of everyone's range scores zero minus zero and beats every hex that -/// trades. That is "far away", not "behind a tree", and it is what -/// `examples/stands.rs` prints. +/// A frontier fixes that without inventing an exchange rate. A stand dealing 25 +/// while taking 50 dominates one dealing 0 while taking 50, so standing where +/// nothing can happen survives only where it is genuinely the safest thing +/// available, and a position that fights is on the frontier whenever one +/// exists. /// -/// The proposal set is the union of the two lists. A caller that wants one -/// ordering applies its own weights to [`Self::scored`], which is every stand. +/// **Still no scalar.** Ranking on one number means choosing a rate at which +/// damage dealt trades against damage taken, and that rate is a tactical +/// opinion: it is what [`crate::features`] and `plan/strategies.md` exist to +/// hold, and it moves with role, with damage already taken, and with whether +/// the force is winning. Dominance needs no rate - it is the partial order the +/// two columns already agree on - which is why it is a candidate generator's +/// answer and a weighted sum is not. `StandScore::margin` was deleted for this +/// reason and does not come back in another shape. +/// +/// [`Self::by_offence`] and [`Self::by_defence`] are kept as instruments. They +/// are the two ends of the frontier and the flat axis beside it, and reading +/// them together is how the degeneracy was found. #[derive(Debug, Clone, PartialEq)] pub struct Ranking { /// Every stand, scored, in the order they were reached. pub scored: Vec, + /// Indices into [`Self::scored`]: the non-dominated set, safest first, at + /// most `frontier_max` of them. + pub frontier: Vec, /// Indices into [`Self::scored`], most dealt first, at most `top_k`. pub by_offence: Vec, /// Indices into [`Self::scored`], least taken first, at most `top_k`. @@ -358,29 +410,230 @@ pub struct Ranking { } impl Ranking { - /// The stands that deal the most, best first. + /// The stands that deal the most, best first. An instrument. pub fn best_offence(&self) -> impl Iterator { self.by_offence.iter().map(|at| &self.scored[*at]) } - /// The stands that take the least, best first. + /// The stands that take the least, best first. An instrument, and on its + /// own a description of the hex nothing can see. pub fn best_defence(&self) -> impl Iterator { self.by_defence.iter().map(|at| &self.scored[*at]) } - /// The proposal set: both lists, each stand once, in a fixed order. + /// The proposal set: the frontier, safest first. + pub fn on_frontier(&self) -> impl Iterator { + self.frontier.iter().map(|at| &self.scored[*at]) + } + + /// The proposal set as indices into [`Self::scored`]. pub fn proposals(&self) -> Vec { - let mut out = self.by_offence.clone(); - for at in &self.by_defence { - if !out.contains(at) { - out.push(*at); + self.frontier.clone() + } +} + +/// The axes a stand is compared on: one gain per enemy, and one loss. +/// +/// **One damage column per target, not a maximum over targets.** Dominance on +/// "best target" is not dominance on anything a force reasons about: a stand +/// that deals less to its own best target but is the only one with a line on +/// the third enemy would be dominated and gone before the force ever saw it, +/// and that is exactly the hex that lets a lance concentrate, screen, or hold a +/// lane. The vector keeps a stand that is best against *any* enemy. +/// +/// Expected damage throughout. It is the finest discriminator of the five +/// columns and the only one both directions of fire express in the same unit, +/// which is what lets "takes no more" mean the same thing as "deals at least as +/// much". The other four columns ride along on every emitted stand and are not +/// compared: putting all five in leaves nothing dominated, and a frontier that +/// is the whole set is not a proposal set. +fn axes(score: &StandScore) -> (Vec, f32) { + ( + score + .per_target + .iter() + .map(|(_, value)| value.expected_damage) + .collect(), + score.defence.expected_damage, + ) +} + +/// The axes the emission used before the per-target ones: best target against +/// damage taken. +/// +/// Kept so the two can be counted against each other on a real board. An answer +/// of "the extra axes rescue nothing" is a finding, and it is not one that can +/// be had without measuring it. +fn summary_axes(score: &StandScore) -> (Vec, f32) { + ( + vec![score.offence.expected_damage], + score.defence.expected_damage, + ) +} + +/// The tiebreak that makes every comparison here total. +fn identity(score: &StandScore) -> (i32, i32, i32) { + ( + score.reach.stand.hex.x, + score.reach.stand.hex.y, + score.reach.stand.facing, + ) +} + +/// Whether `a` dominates `b`: deals at least as much to **every** enemy, takes +/// no more, and is strictly better on at least one of those axes. +pub fn dominates(a: &StandScore, b: &StandScore) -> bool { + dominates_on(a, b, axes) +} + +fn dominates_on(a: &StandScore, b: &StandScore, axes: fn(&StandScore) -> (Vec, f32)) -> bool { + let (a_deal, a_take) = axes(a); + let (b_deal, b_take) = axes(b); + if a_deal.len() != b_deal.len() || a_take > b_take { + return false; + } + let mut strict = a_take < b_take; + for (mine, theirs) in a_deal.iter().zip(b_deal.iter()) { + if mine < theirs { + return false; + } + if mine > theirs { + strict = true; + } + } + strict +} + +/// The non-dominated stands, safest first. +/// +/// Pairwise, because with one damage column per enemy there is no sweep: the +/// ordering that made a single gain decidable in one pass does not exist in `N` +/// of them. `L` is a few hundred states and this runs once per unit per turn, +/// so `O(n^2)` over a handful of f32 comparisons is affordable, and it is +/// exactly right rather than nearly right. +/// +/// Stands equal on every axis dominate nothing and are all kept, which is what +/// makes "every stand identical" come out as the whole set rather than one +/// arbitrary member of it. +/// +/// The output order is total, ending in hex and facing, so it does not depend +/// on the order the stands were reached in. +pub fn pareto_frontier(scored: &[StandScore]) -> Vec { + non_dominated(scored, axes) +} + +/// The frontier the two-axis rule would have emitted: best target against +/// damage taken. +/// +/// An instrument. Counting it beside [`pareto_frontier`] is what answers "do +/// the per-target axes rescue anything", and that answer belongs in a log +/// rather than in an argument. +pub fn summary_frontier(scored: &[StandScore]) -> Vec { + non_dominated(scored, summary_axes) +} + +fn non_dominated(scored: &[StandScore], axes: fn(&StandScore) -> (Vec, f32)) -> Vec { + let mut keep: Vec = (0..scored.len()) + .filter(|at| { + !scored + .iter() + .any(|other| dominates_on(other, &scored[*at], axes)) + }) + .collect(); + + // Safest first, and total. + keep.sort_by(|a, b| { + let (x, y) = (&scored[*a], &scored[*b]); + let ((x_deal, x_take), (y_deal, y_take)) = (axes(x), axes(y)); + let total = |values: &[f32]| values.iter().sum::(); + x_take + .total_cmp(&y_take) + .then(total(&x_deal).total_cmp(&total(&y_deal))) + .then(identity(x).cmp(&identity(y))) + }); + keep +} + +/// Cut a frontier down to `limit` stands by spreading the picks along it. +/// +/// The two ends go in first - the safest stand and the hardest hitting one - +/// and then, repeatedly, whichever remaining stand is furthest from everything +/// already picked, with both axes normalised to the frontier's own range so +/// that neither unit swamps the distance. Taking the first `limit` instead +/// would hand back one end of the curve, which is the failure the frontier +/// exists to fix. +/// +/// `limit` of 0 is no bound. The caller records the length before and after: +/// see [`StandStats::frontier`] and [`StandStats::emitted`]. +pub fn thin_frontier(scored: &[StandScore], frontier: &[usize], limit: usize) -> Vec { + if limit == 0 || frontier.len() <= limit { + return frontier.to_vec(); + } + if limit == 1 { + return vec![frontier[0]]; + } + + // One point per frontier stand: a damage column per enemy, then the loss. + let points: Vec> = frontier + .iter() + .map(|at| { + let (mut deal, take) = axes(&scored[*at]); + deal.push(take); + deal + }) + .collect(); + let width = points.first().map(Vec::len).unwrap_or(0); + // Normalised per axis to the frontier's own range, so a damage column and + // the loss column weigh the same and no unit swamps the distance. + let spans: Vec = (0..width) + .map(|axis| { + let low = points.iter().map(|p| p[axis]).fold(f32::INFINITY, f32::min); + let high = points + .iter() + .map(|p| p[axis]) + .fold(f32::NEG_INFINITY, f32::max); + let span = high - low; + if span > 0.0 { + span + } else { + 1.0 + } + }) + .collect(); + let gap = |a: usize, b: usize| { + (0..width) + .map(|axis| ((points[a][axis] - points[b][axis]) / spans[axis]).powi(2)) + .sum::() + .sqrt() + }; + + let mut picked = vec![0usize, frontier.len() - 1]; + while picked.len() < limit { + let mut best: Option<(f32, usize)> = None; + for pos in 0..frontier.len() { + if picked.contains(&pos) { + continue; } + let nearest = picked + .iter() + .map(|held| gap(pos, *held)) + .fold(f32::INFINITY, f32::min); + // Strictly greater, so a tie keeps the earlier position and the + // answer does not depend on the walk. + if best.is_none_or(|(held, _)| nearest > held) { + best = Some((nearest, pos)); + } + } + match best { + Some((_, pos)) => picked.push(pos), + None => break, } - out } + picked.sort_unstable(); + picked.into_iter().map(|pos| frontier[pos]).collect() } -/// Score every stand against every enemy, and emit the top `K`. +/// Score every stand against every enemy, and emit the frontier through them. /// /// The loop nest is `L x N x M`, and every enemy's [`TargetState`] is built once /// per position rather than once per stand. The two expensive things @@ -412,6 +665,7 @@ pub fn score_stands( let mut offence: Option<(i32, Aggregate)> = None; let mut defence = Aggregate::default(); + let mut per_target: Vec<(i32, Aggregate)> = Vec::with_capacity(foes.len()); for foe in foes { let mut dealt: Vec = Vec::with_capacity(foe.may_be.len()); @@ -439,6 +693,9 @@ pub fn score_stands( let mine = Aggregate::collapse(&dealt, params.exponent, Sense::Gain); let theirs = Aggregate::collapse(&taken, params.exponent, Sense::Loss); + // Every target, kept: this is what the frontier compares on. + per_target.push((foe.who.unit.id, mine)); + // Offence: the best target. Defence: all of them. if offence .as_ref() @@ -464,6 +721,7 @@ pub fn score_stands( offence, defence, best_target, + per_target, }); } @@ -494,7 +752,20 @@ pub fn score_stands( order }; + // The proposal set. Not a ranking: a frontier has no best member, which is + // the point of emitting one. + let whole = pareto_frontier(&scored); + stats.frontier = whole.len() as u64; + // What the two-axis rule would have kept, and what the extra axes saved + // from it. Counted rather than argued about. + let summary: BTreeSet = summary_frontier(&scored).into_iter().collect(); + stats.summary_frontier = summary.len() as u64; + stats.rescued = whole.iter().filter(|at| !summary.contains(at)).count() as u64; + let frontier = thin_frontier(&scored, &whole, params.frontier_max); + stats.emitted = frontier.len() as u64; + Ranking { + frontier, by_offence: rank(|score| score.offence.objective(), true), by_defence: rank(|score| score.defence.objective(), false), scored, @@ -502,6 +773,132 @@ pub fn score_stands( } } +/// The frontier as [`Proposal`]s, measured in the existing feature basis. +/// +/// The gap this closes: the estimator produced [`StandScore`]s and the strategy +/// layer consumes [`Proposal`]s, and nothing joined them, so a frontier could +/// not be scored by weights and no stance could pick along it. This is that +/// join and nothing else - it measures, it does not choose, and it adds no +/// feature name that was not already in the basis. +/// +/// **Positional features come from [`Posture`]**, which is the same survey +/// `sds-bot` runs on its four verbs: two traces per enemy through the shared +/// cache, and every one of `exposure`, `los_in`, `los_out`, `cover_quality`, +/// `range_band_fit`, `elevation_gain`, `tmm_gained`, `cohesion` and +/// `rear_arc_gain` read off it. They are a function of the hex, not of the +/// facing, so two stands on one hex differ only in what they can shoot. +/// +/// **Firing features are recorded from the aggregate** rather than re-derived +/// from a `Volley`: the aggregate is a collapse over where the enemy could be, +/// and there is no single volley left to hand the measurement. Four of the +/// firing group are expressible that way - `expected_damage`, `p_kill`, +/// `p_mission_kill` and `value_destroyed` - plus `damage_lead` across this +/// menu. The rest of that group needs a chosen shot: `heat_incurred`, +/// `ammo_spent`, `overkill`, `weapon_concentration` and `p_psr_threshold` are +/// properties of a weapon allocation, and `p_breach` is a probability where +/// [`Aggregate`] carries an expected count. A movement proposal has never +/// carried them and does not start here. +/// +/// `damage_by_target` and `damage_taken` are the frontier's own axes, +/// unnormalised, which is what a reader wants next to a normalised vector. +/// `damage_dealt` is the summary beside them. +pub fn propose( + los: &LosCache, + us: &Mover<'_>, + ranking: &Ranking, + foes: &[Foe<'_>], + friends: &[&Unit], +) -> Vec { + let enemies: Vec<&Unit> = foes.iter().map(|foe| foe.who.unit).collect(); + let board_span = los.board().width().max(los.board().height()); + let picks: Vec<&StandScore> = ranking.on_frontier().collect(); + // `damage_lead` is min-maxed across this decision's own candidates, so no + // candidate can be measured until every candidate's damage is known. Same + // two-pass shape the bot's own menu uses, for the same reason. + let spread: Vec = picks + .iter() + .map(|score| score.offence.expected_damage) + .collect(); + + let mut proposals = Vec::with_capacity(picks.len()); + for score in &picks { + let end = score.reach.stand.hex; + let sightings = Posture::survey(los, end, &enemies); + let mut features = FeatureVector::new(); + features.extend( + &Posture { + unit: us.who.unit, + end, + hexes_moved: score.reach.hexes_moved, + jumped: us.jumped, + level: los.board().hex(end).level, + sightings: &sightings, + friends, + board_span, + } + .features(), + ); + + // The target the offence column describes. Without one there is nothing + // to normalise a share of the target against, and the firing group + // reads zero rather than a made-up denominator. + let target = score + .best_target + .and_then(|id| enemies.iter().find(|unit| unit.id == id)); + let (remaining, at_stake) = match target { + Some(unit) => ( + (unit.armor + unit.internal).max(1) as f32, + unit.locations + .iter() + .map(|location| location.value()) + .sum::(), + ), + None => (1.0, 0.0), + }; + features.record::(Score::fraction( + score.offence.expected_damage / remaining, + )); + features.record::(Score::probability(score.offence.p_kill)); + features.record::(Score::probability(score.offence.p_mission_kill)); + features.record::(Score::fraction(if at_stake > 0.0 { + score.offence.value_destroyed / at_stake + } else { + 0.0 + })); + let lead = AmongCandidates::of(score.offence.expected_damage, &spread); + features.record::(Score::min_max(lead.raw, lead.lowest, lead.highest)); + + proposals.push(Proposal { + unit: us.who.unit.id, + label: format!( + "stand at ({}, {}) facing {}", + end.x, end.y, score.reach.stand.facing + ), + // The step list is the pathfinder's, and `Reach` records where a + // path ended rather than how it got there. A caller that has to + // send this to a host fills it in; nothing here pretends to. + action: Action::Move { steps: Vec::new() }, + end: Some(end), + features, + damage_dealt: score.offence.expected_damage, + // The frontier's own gain axes, unnormalised and keyed. This is the + // column that must not be collapsed on the way up. + damage_by_target: score + .per_target + .iter() + .map(|(id, value)| (*id, value.expected_damage)) + .collect(), + damage_taken: score.defence.expected_damage, + // No piloting model in this path yet. Zero rather than a guess: a + // number nobody computed is worse than a column that is honestly + // empty. + risk: 0.0, + notes: Vec::new(), + }); + } + proposals +} + /// Whether any weapon bears on a hex from a stand, at any legal twist. /// /// Arc only - no range bracket, no line of sight, no statistics. This is the @@ -795,6 +1192,7 @@ mod tests { let params = Params { exponent: 1.0, top_k: 4, + frontier_max: 0, }; let mut cache = VolleyCache::new(); @@ -876,7 +1274,11 @@ mod tests { let mut previous_offence = f32::INFINITY; let mut previous_defence = f32::NEG_INFINITY; for exponent in [1.0f32, 0.5, -1.0, -4.0, f32::NEG_INFINITY] { - let params = Params { exponent, top_k: 1 }; + let params = Params { + exponent, + top_k: 1, + frontier_max: 0, + }; let scored = &score_stands(&mut cache, &los, &mover, &stands, &foes, ¶ms).scored[0]; assert!( scored.offence.expected_damage <= previous_offence + 1e-4, @@ -920,6 +1322,7 @@ mod tests { let params = Params { exponent: f32::NEG_INFINITY, top_k: 5, + frontier_max: 0, }; let mut cache = VolleyCache::new(); let first = score_stands(&mut cache, &los, &mover, &stands, &foes, ¶ms); @@ -955,11 +1358,14 @@ mod tests { // testing the split. assert_ne!(first.by_offence, first.by_defence); - // The proposal set is the union, each stand once. + // The proposal set is the frontier, each stand once, and it does not + // move between runs either. + assert_eq!(first.frontier, second.frontier); let proposals = first.proposals(); let unique: BTreeSet = proposals.iter().copied().collect(); assert_eq!(proposals.len(), unique.len()); - assert!(proposals.len() <= 10 && proposals.len() > 5); + assert_eq!(first.stats.frontier, first.frontier.len() as u64); + assert_eq!(first.stats.emitted, first.frontier.len() as u64); } /// Both prunes counted over the same pairs, which is what makes the order a @@ -991,4 +1397,268 @@ mod tests { assert_eq!(counts.both_drop, 0); assert!(counts.arc_share() > 0.0 && counts.los_share() == 0.0); } + + /// A stand with a stated pair of axis values and nothing else. + /// + /// The frontier reads exactly two numbers off a [`StandScore`], so a + /// hand-built one is the honest fixture for the partial order: it puts the + /// cases on the page instead of hoping a board produces them. + fn scoreline(x: i32, y: i32, facing: i32, deal: f32, take: f32) -> StandScore { + against(x, y, facing, &[deal], take) + } + + /// The same, with one damage column per enemy. Enemy ids are `2..`, the way + /// the example numbers them. + fn against(x: i32, y: i32, facing: i32, deals: &[f32], take: f32) -> StandScore { + let per_target: Vec<(i32, Aggregate)> = deals + .iter() + .enumerate() + .map(|(i, deal)| { + ( + 2 + i as i32, + Aggregate { + expected_damage: *deal, + ..Aggregate::default() + }, + ) + }) + .collect(); + let best = per_target + .iter() + .max_by(|a, b| a.1.expected_damage.total_cmp(&b.1.expected_damage)) + .cloned(); + StandScore { + reach: reach(Coord::new(x, y), facing), + offence: best.map(|(_, value)| value).unwrap_or_default(), + defence: Aggregate { + expected_damage: take, + ..Aggregate::default() + }, + best_target: match deals.iter().cloned().fold(0.0f32, f32::max) > 0.0 { + true => best_id(&per_target), + false => None, + }, + per_target, + } + } + + fn best_id(per_target: &[(i32, Aggregate)]) -> Option { + per_target + .iter() + .max_by(|a, b| a.1.expected_damage.total_cmp(&b.1.expected_damage)) + .map(|(id, _)| *id) + } + + fn placed(scored: &[StandScore], picks: &[usize]) -> Vec<(i32, i32, i32)> { + picks.iter().map(|at| identity(&scored[*at])).collect() + } + + /// The partial order, both directions, on the four cases that matter. + #[test] + fn a_dominated_stand_is_never_emitted_and_a_free_one_always_is() { + let scored = vec![ + // 0: fights hard and is shot at hard. The offensive end. + scoreline(1, 1, 0, 30.0, 60.0), + // 1: trades less for less. Nothing dominates it. + scoreline(2, 2, 0, 12.0, 20.0), + // 2: the hide corner, and here it really is the safest hex. + scoreline(3, 3, 0, 0.0, 5.0), + // 3: dominated by 1 - deals less and takes more. + scoreline(4, 4, 0, 8.0, 25.0), + // 4: dominated by 1 on one axis only, which is enough. Same damage + // dealt, strictly more taken. + scoreline(5, 5, 0, 12.0, 21.0), + // 5: dominated by 2. Deals nothing and takes more for it. + scoreline(6, 6, 0, 0.0, 40.0), + ]; + let frontier = pareto_frontier(&scored); + assert_eq!(frontier, vec![2, 1, 0], "frontier was {frontier:?}"); + + assert!(dominates(&scored[1], &scored[3])); + assert!(dominates(&scored[1], &scored[4])); + assert!(dominates(&scored[2], &scored[5])); + // The frontier's own members never dominate each other. That is what + // makes it a set rather than a ranking. + for a in &frontier { + for b in &frontier { + assert!(!dominates(&scored[*a], &scored[*b]), "{a} dominated {b}"); + } + } + } + + /// The completion-order invariant, on the emitted set. + #[test] + fn the_frontier_does_not_depend_on_input_order() { + let base = vec![ + scoreline(1, 1, 0, 30.0, 60.0), + scoreline(2, 2, 1, 12.0, 20.0), + scoreline(3, 3, 2, 0.0, 5.0), + scoreline(4, 4, 3, 8.0, 25.0), + scoreline(5, 5, 4, 12.0, 21.0), + scoreline(6, 6, 5, 12.0, 20.0), + ]; + let expected = placed(&base, &pareto_frontier(&base)); + + // Every rotation of the input, which is enough to catch a sweep that + // depends on what it happened to see first. + for shift in 1..base.len() { + let mut rotated = base.clone(); + rotated.rotate_left(shift); + let got = placed(&rotated, &pareto_frontier(&rotated)); + assert_eq!(got, expected, "rotation by {shift} moved the frontier"); + } + + // And reversed, which a stable sort alone would not survive. + let mut backwards = base.clone(); + backwards.reverse(); + assert_eq!(placed(&backwards, &pareto_frontier(&backwards)), expected); + } + + /// The sentence the frontier exists for, said twice. + #[test] + fn the_hide_corner_survives_only_when_nothing_beats_it_outright() { + // Safest, and nothing else is as safe. It stays. + let safest = vec![ + scoreline(1, 1, 0, 0.0, 5.0), + scoreline(2, 2, 0, 20.0, 30.0), + scoreline(3, 3, 0, 25.0, 50.0), + ]; + let frontier = pareto_frontier(&safest); + assert!(frontier.contains(&0), "the safest hex left the frontier"); + assert_eq!(frontier.len(), 3); + + // Now something deals more and takes no more. The hide corner is not a + // trade-off any more, it is just worse, and it goes. + let beaten = vec![ + scoreline(1, 1, 0, 0.0, 50.0), + scoreline(2, 2, 0, 25.0, 50.0), + scoreline(3, 3, 0, 31.0, 60.0), + ]; + let frontier = pareto_frontier(&beaten); + assert!( + !frontier.contains(&0), + "a stand dealing nothing for the same punishment stayed on the frontier" + ); + assert_eq!(frontier, vec![1, 2]); + } + + /// The three degenerate boards, which are the ones a partial order gets + /// wrong quietly. + #[test] + fn the_degenerate_boards_come_out_right() { + // One stand. It is the frontier: nothing dominates it, because there is + // nothing. + let single = vec![scoreline(1, 1, 0, 7.0, 9.0)]; + assert_eq!(pareto_frontier(&single), vec![0]); + assert!(pareto_frontier(&[]).is_empty()); + + // Every stand identical. None dominates any other - domination needs a + // strict improvement somewhere - so all of them are the frontier, and + // the bound is what makes that a usable answer. + let same: Vec = (0..8) + .map(|f| scoreline(2, 2 + f, f % 6, 11.0, 22.0)) + .collect(); + let frontier = pareto_frontier(&same); + assert_eq!(frontier.len(), same.len()); + assert_eq!(thin_frontier(&same, &frontier, 3).len(), 3); + + // Every stand deals zero. The frontier is the least-shot stands and + // nothing else, which is the hide corner - correctly, because on this + // board there is nothing else to be. + let mute = vec![ + scoreline(1, 1, 0, 0.0, 5.0), + scoreline(2, 2, 0, 0.0, 5.0), + scoreline(3, 3, 0, 0.0, 9.0), + scoreline(4, 4, 0, 0.0, 40.0), + ]; + assert_eq!(pareto_frontier(&mute), vec![0, 1]); + } + + /// The reason the gain axis is a vector: a stand that is the only one with + /// a line on the third enemy. + /// + /// On "best target against damage taken" it is dominated and gone before + /// the force sees it. On one column per target it is not dominated by + /// anything, because nothing else deals a thing to enemy 4. + #[test] + fn a_stand_that_is_the_only_line_on_one_enemy_survives() { + let scored = vec![ + // Deals more to its own best target and is shot at less. + against(1, 1, 0, &[30.0, 10.0, 0.0], 40.0), + // Deals less everywhere except to the third enemy, which nothing + // else can touch at all. + against(2, 2, 0, &[12.0, 4.0, 9.0], 50.0), + against(3, 3, 0, &[0.0, 0.0, 0.0], 5.0), + ]; + + let summary = summary_frontier(&scored); + assert!( + !summary.contains(&1), + "the two-axis rule kept the stand, so this is not testing anything" + ); + + let frontier = pareto_frontier(&scored); + assert!( + frontier.contains(&1), + "the only line on enemy 4 was pruned: {frontier:?}" + ); + // And the extra axes do not save something that is worse everywhere. + let worse = vec![ + against(1, 1, 0, &[30.0, 10.0, 9.0], 40.0), + against(2, 2, 0, &[12.0, 4.0, 9.0], 50.0), + ]; + assert_eq!(pareto_frontier(&worse), vec![0]); + } + + /// The bound keeps both ends and spreads what it keeps over the range. + #[test] + fn thinning_spreads_along_the_frontier() { + // A frontier bunched at the safe end: thirty stands crowded into the + // first hundredth of the damage range, and five spread over the rest of + // it. Taking the first five would return five near-identical hide + // hexes, which is the failure this bound must not reintroduce. + let mut scored: Vec = (0..30) + .map(|i| scoreline(i, 0, 0, i as f32 * 0.01, i as f32 * 0.1)) + .collect(); + for step in 1..=5 { + scored.push(scoreline( + 50 + step, + 0, + 0, + step as f32 * 6.0, + step as f32 * 12.0, + )); + } + let frontier = pareto_frontier(&scored); + assert_eq!(frontier.len(), scored.len()); + + let thinned = thin_frontier(&scored, &frontier, 5); + assert_eq!(thinned.len(), 5); + // Both ends, always. + assert_eq!(thinned.first(), frontier.first()); + assert_eq!(thinned.last(), frontier.last()); + + let deals: Vec = thinned.iter().map(|at| axes(&scored[*at]).0[0]).collect(); + for pair in deals.windows(2) { + assert!(pair[0] < pair[1], "thinned set is not in order: {deals:?}"); + } + // Most of the picks fight, even though most of the frontier does not. + // The first five of the frontier are all in the crowd. + let fighting = deals.iter().filter(|deal| **deal > 1.0).count(); + assert!(fighting >= 3, "the picks bunched at one end: {deals:?}"); + let naive: Vec = frontier[..5] + .iter() + .map(|at| axes(&scored[*at]).0[0]) + .collect(); + assert!( + naive.iter().all(|deal| *deal <= 1.0), + "the fixture does not bunch, so it is not testing the thinning" + ); + + // Deterministic, and a bound at or above the length is a no-op. + assert_eq!(thin_frontier(&scored, &frontier, 5), thinned); + assert_eq!(thin_frontier(&scored, &frontier, 0), frontier); + assert_eq!(thin_frontier(&scored, &frontier, 99), frontier); + assert_eq!(thin_frontier(&scored, &frontier, 1), vec![frontier[0]]); + } } diff --git a/crates/sds-core/tests/stands_frontier.rs b/crates/sds-core/tests/stands_frontier.rs new file mode 100644 index 0000000..97510cb --- /dev/null +++ b/crates/sds-core/tests/stands_frontier.rs @@ -0,0 +1,448 @@ +//! The frontier on a real board, and the regression it exists to prevent. +//! +//! `crates/sds-core/src/stands.rs` tests the partial order on hand-built +//! scorelines, which is where the edge cases belong. This asks the other +//! question: on a board MegaMek dumped, with three enemies that have not moved, +//! does the emitted set contain anything that can shoot? +//! +//! It has to, and it did not. Emitting the top `K` by damage taken selected the +//! hex nothing could see, by construction - stands that dealt 0.00, and at +//! `p = 1` a run for the map edge. That is the failure this file guards, and it +//! is named rather than folded into a general test so a regression says so. +//! +//! The board and the unit are the ones `examples/stands.rs` prints, so a +//! surprise here can be read there. + +use std::collections::BTreeSet; + +use sds_core::arc::MekLocation; +use sds_core::hex::Stand; +use sds_core::los::{LosCache, Rules}; +use sds_core::pathfind::{reachable, MoveBoard, Reach, Walker, MEK_MAX_ELEVATION_CHANGE}; +use sds_core::stands::{dominates, propose, score_stands, Combatant, Foe, Mover, Params, Presence}; +use sds_core::volley::{MountedWeapon, VolleyCache}; +use sds_core::wire::{Board, BoardHex, Coord, Unit, Weapon}; + +const CORPUS: &str = include_str!("corpus/pathfind.jsonl"); + +const OUR_MP: i32 = 6; +const THEIR_MP: i32 = 4; +const START: Coord = Coord::new(4, 13); +const ENEMIES: [Coord; 3] = [Coord::new(5, 6), Coord::new(9, 6), Coord::new(12, 7)]; + +fn board(name: &str) -> Board { + for line in CORPUS.lines() { + let value: serde_json::Value = serde_json::from_str(line).expect("corpus line parses"); + if value["type"] != "board" || value["board"] != name { + continue; + } + let hexes: Vec = + serde_json::from_value(value["hexes"].clone()).expect("hexes parse"); + return Board { + width: value["width"].as_i64().unwrap() as i32, + height: value["height"].as_i64().unwrap() as i32, + hexes, + }; + } + panic!("no board named {name} in the corpus"); +} + +fn mek(id: i32, at: Coord, facing: i32) -> Unit { + let mut unit: Unit = serde_json::from_str(&format!( + r#"{{ + "id": {id}, "name": "Mek {id}", "ownerId": 2, "team": 2, + "friendly": false, "x": {}, "y": {}, "facing": {facing}, + "weight": 55.0, "walkMp": 4, "runMp": 6, + "armor": 100, "armorMax": 100, "internal": 50, "internalMax": 50, + "heat": 0, "heatCapacity": 10, "gunnery": 4, "piloting": 5, + "locations": [ + {{"name": "HD", "armor": 9, "armorMax": 9, + "internal": 3, "internalMax": 3, "cockpit": true}}, + {{"name": "CT", "armor": 20, "armorMax": 22, + "rearArmor": 6, "rearArmorMax": 8, + "internal": 18, "internalMax": 18, + "engine": true, "gyro": true, "weapons": 1, "weaponDamage": 5.0}}, + {{"name": "RT", "armor": 16, "armorMax": 16, + "rearArmor": 5, "rearArmorMax": 5, + "internal": 13, "internalMax": 13, "engine": true}}, + {{"name": "LT", "armor": 16, "armorMax": 16, + "rearArmor": 5, "rearArmorMax": 5, + "internal": 13, "internalMax": 13, + "engine": true, "weapons": 1, "weaponDamage": 10.0}}, + {{"name": "RA", "armor": 12, "armorMax": 12, + "internal": 9, "internalMax": 9, "actuators": 4, + "weapons": 1, "weaponDamage": 10.0}}, + {{"name": "LA", "armor": 12, "armorMax": 12, + "internal": 9, "internalMax": 9, "actuators": 4, + "weapons": 1, "weaponDamage": 10.0}}, + {{"name": "RL", "armor": 16, "armorMax": 16, + "internal": 13, "internalMax": 13, "actuators": 4}}, + {{"name": "LL", "armor": 16, "armorMax": 16, + "internal": 13, "internalMax": 13, "actuators": 4}} + ], + "weapons": [] + }}"#, + at.x, at.y + )) + .expect("unit parses"); + // The same guns, on the wire this time. + // + // `Combatant` is handed `MountedWeapon`s and the volley estimator reads + // those, but `features::positional` reads `Unit::weapons` - it projects + // damage at a range rather than estimating a volley. A unit with an empty + // wire list is a unit `exposure` and `range_band_fit` both read as zero + // for, on every candidate, which is a flat column and not a measurement. + unit.weapons = loadout_wire(); + unit +} + +fn wire_weapon( + id: i32, + damage: f32, + short: i32, + medium: i32, + long: i32, + location: MekLocation, +) -> Weapon { + let mount = location.abbreviation(); + serde_json::from_str(&format!( + r#"{{ + "id": {id}, "name": "Gun {id}", "heat": 3, "location": "{mount}", + "short": {short}, "medium": {medium}, "long": {long}, + "avgDamageShort": {damage}, "avgDamageMedium": {damage}, + "avgDamageLong": {damage}, "rackSize": 0, + "damagePerPacket": {damage}, "usable": true + }}"# + )) + .expect("weapon parses") +} + +/// An AC/20 in the right arm, a PPC in the left torso, an LRM-10 in the right. +fn loadout_wire() -> Vec { + let lrm: Weapon = serde_json::from_str( + r#"{ + "id": 3, "name": "LRM-10", "heat": 4, "location": "RT", + "short": 7, "medium": 14, "long": 21, + "avgDamageShort": 6.0, "avgDamageMedium": 6.0, "avgDamageLong": 6.0, + "rackSize": 10, "damagePerPacket": 1.0, "usable": true + }"#, + ) + .expect("weapon parses"); + vec![ + wire_weapon(1, 20.0, 3, 6, 9, MekLocation::RightArm), + wire_weapon(2, 10.0, 6, 12, 18, MekLocation::LeftTorso), + lrm, + ] +} + +fn loadout() -> Vec { + loadout_wire() + .iter() + .map(MountedWeapon::from_wire) + .collect() +} + +/// Everything one board needs, held together so the borrows outlive the sweep. +struct Fight { + message: Board, + our_unit: Unit, + enemy_units: Vec, + stands: Vec, +} + +impl Fight { + fn on(name: &str) -> Self { + let message = board(name); + let move_board = MoveBoard::new(&message); + let occupied: Vec = ENEMIES.to_vec(); + let stands = reachable( + &move_board, + &Walker { + start: Stand::new(START, 0), + mp: OUR_MP, + max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + prone: false, + }, + &occupied, + ); + let enemy_units = ENEMIES + .iter() + .enumerate() + .map(|(i, at)| mek(2 + i as i32, *at, 3)) + .collect(); + Self { + message, + our_unit: mek(1, START, 0), + enemy_units, + stands, + } + } + + fn foes(&self) -> Vec> { + let move_board = MoveBoard::new(&self.message); + self.enemy_units + .iter() + .map(|unit| { + let start = Stand::new(Coord::new(unit.x, unit.y), unit.facing); + let mut blocked: Vec = ENEMIES + .iter() + .filter(|at| **at != start.hex) + .copied() + .collect(); + blocked.push(START); + let may_be = reachable( + &move_board, + &Walker { + start, + mp: THEIR_MP, + max_elevation_change: MEK_MAX_ELEVATION_CHANGE, + prone: false, + }, + &blocked, + ) + .into_iter() + .map(|reach| Presence { + stand: reach.stand, + elevation: 0, + hexes_moved: reach.hexes_moved, + jumped: false, + }) + .collect(); + Foe { + who: Combatant::mek(unit, loadout(), 4), + may_be, + } + }) + .collect() + } + + fn mover(&self) -> Mover<'_> { + Mover { + who: Combatant::mek(&self.our_unit, loadout(), 4), + elevation: 0, + jumped: false, + } + } +} + +/// **The regression guard for the whole problem.** +/// +/// The proposal set must contain a stand that can shoot. Top `K` by damage +/// taken could not: least shot at is a description of least able to shoot, so +/// its picks came out at `deal 0.00`. If this fails, the emitted set is a +/// diagram of running away. +#[test] +fn the_open_board_frontier_contains_a_stand_that_fights() { + let fight = Fight::on("open"); + let los = LosCache::new(&fight.message, Rules::default()); + let foes = fight.foes(); + let mover = fight.mover(); + let mut cache = VolleyCache::new(); + + for exponent in [f32::NEG_INFINITY, -1.0, 1.0] { + let params = Params { + exponent, + top_k: 8, + frontier_max: 12, + }; + let ranking = score_stands(&mut cache, &los, &mover, &fight.stands, &foes, ¶ms); + assert!( + !ranking.frontier.is_empty(), + "no frontier at p = {exponent}" + ); + + let fighting = ranking + .on_frontier() + .filter(|score| score.offence.expected_damage > 0.0) + .count(); + assert!( + fighting > 0, + "at p = {exponent} the frontier proposes nothing that can shoot: {:?}", + ranking + .on_frontier() + .map(|s| (s.reach.stand.hex.x, s.reach.stand.hex.y)) + .collect::>() + ); + + // And it spans a range rather than sitting at one end. The safest stand + // and the hardest hitting one are different stands. + let dealt: Vec = ranking + .on_frontier() + .map(|s| s.offence.expected_damage) + .collect(); + let taken: Vec = ranking + .on_frontier() + .map(|s| s.defence.expected_damage) + .collect(); + // Least taken first. Damage is not monotone along it - with one gain + // axis per enemy the frontier is a surface rather than a curve - so the + // spread is checked as a spread and not as an ordering. + if ranking.frontier.len() > 1 { + let span = |values: &[f32]| { + values.iter().cloned().fold(0.0f32, f32::max) + - values.iter().cloned().fold(f32::INFINITY, f32::min) + }; + assert!( + span(&dealt) > 0.0, + "at p = {exponent} the frontier does not span any damage: {dealt:?}" + ); + assert!( + span(&taken) > 0.0, + "at p = {exponent} the frontier does not span any risk: {taken:?}" + ); + for pair in taken.windows(2) { + assert!(pair[0] <= pair[1], "the frontier is not safest first"); + } + } + } +} + +/// The defence list, on the same board, is the thing being replaced. +/// +/// Kept as an instrument and printed beside the frontier, so this pins what it +/// actually is: at `p = 1` its first pick is not a fighting position, and the +/// frontier's hardest hitter deals many times what it does. +#[test] +fn the_defence_list_is_the_degenerate_one() { + let fight = Fight::on("open"); + let los = LosCache::new(&fight.message, Rules::default()); + let foes = fight.foes(); + let mover = fight.mover(); + let mut cache = VolleyCache::new(); + let params = Params { + exponent: 1.0, + top_k: 8, + frontier_max: 12, + }; + let ranking = score_stands(&mut cache, &los, &mover, &fight.stands, &foes, ¶ms); + + let quiet = ranking + .best_defence() + .filter(|score| score.offence.expected_damage < 7.0) + .count(); + assert!( + quiet >= 4, + "the defence list has stopped being degenerate, which is worth knowing" + ); + + let hardest = ranking + .on_frontier() + .map(|score| score.offence.expected_damage) + .fold(0.0f32, f32::max); + let defensive = ranking + .best_defence() + .map(|score| score.offence.expected_damage) + .fold(0.0f32, f32::max); + assert!( + hardest > defensive, + "the frontier deals no more than the defence list: {hardest} against {defensive}" + ); +} + +/// Nothing on the frontier dominates anything else on it, on a real board. +#[test] +fn the_emitted_set_is_free_of_domination_on_every_board() { + for name in ["open", "forest", "water"] { + let fight = Fight::on(name); + let los = LosCache::new(&fight.message, Rules::default()); + let foes = fight.foes(); + let mover = fight.mover(); + let mut cache = VolleyCache::new(); + let ranking = score_stands( + &mut cache, + &los, + &mover, + &fight.stands, + &foes, + &Params::default(), + ); + + for a in &ranking.frontier { + for b in &ranking.frontier { + assert!( + !dominates(&ranking.scored[*a], &ranking.scored[*b]), + "on {name} a frontier stand dominated another" + ); + } + } + // Nothing anywhere in the scored set dominates a frontier member. + for score in &ranking.scored { + for at in &ranking.frontier { + assert!( + !dominates(score, &ranking.scored[*at]), + "on {name} a stand off the frontier dominated one on it" + ); + } + } + // Emitted once each. + let unique: BTreeSet = ranking.frontier.iter().copied().collect(); + assert_eq!(unique.len(), ranking.frontier.len()); + assert!(ranking.frontier.len() <= Params::default().frontier_max); + } +} + +/// The frontier becomes proposals in the basis the force already scores with. +#[test] +fn every_proposal_carries_the_basis_the_force_reads() { + let fight = Fight::on("open"); + let los = LosCache::new(&fight.message, Rules::default()); + let foes = fight.foes(); + let mover = fight.mover(); + let mut cache = VolleyCache::new(); + let ranking = score_stands( + &mut cache, + &los, + &mover, + &fight.stands, + &foes, + &Params::default(), + ); + let proposals = propose(&los, &mover, &ranking, &foes, &[]); + assert_eq!(proposals.len(), ranking.frontier.len()); + + for proposal in &proposals { + assert_eq!(proposal.unit, fight.our_unit.id); + assert!(proposal.end.is_some()); + for name in [ + "exposure", + "los_in", + "los_out", + "cover_quality", + "range_band_fit", + "elevation_gain", + "tmm_gained", + "cohesion", + "rear_arc_gain", + "expected_damage", + "p_kill", + "p_mission_kill", + "value_destroyed", + "damage_lead", + ] { + assert!( + proposal.features.get(name).is_some(), + "a proposal is missing {name}" + ); + } + } + + // The gain axes travel unnormalised beside the vector: one column per + // enemy, keyed, and not collapsed to the summary scalar. + for proposal in &proposals { + assert_eq!(proposal.damage_by_target.len(), foes.len()); + for foe in &foes { + assert!(proposal.damage_by_target.contains_key(&foe.who.unit.id)); + } + let most = proposal + .damage_by_target + .values() + .cloned() + .fold(0.0f32, f32::max); + assert!( + (most - proposal.damage_dealt).abs() < 1e-3, + "the summary scalar is not the best target's column" + ); + } + let dealt: Vec = proposals.iter().map(|p| p.damage_dealt).collect(); + assert!(dealt.iter().cloned().fold(0.0f32, f32::max) > 0.0); +} -- 2.51.2