From f209b47f4f0da96ae2fb2448865b6ab1bcfe61d0 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 18:30:24 -0400 Subject: [PATCH] feat(candidates)!: report every scored stand instead of the frontier A unit no longer prunes before it reports upward. The frontier was a prune, and the measurements say a prune here is pure loss: 16v16 unpruned is 2,416 candidates and 41k multiply-adds, and ForceThinker::command is linear in what a unit hands it. It stays as an instrument. The capped-candidates invariant is replaced with what is now true. --- CLAUDE.md | 14 ++++-- crates/sds-core/src/stands.rs | 63 +++++++++++++++++------- crates/sds-core/tests/stands_frontier.rs | 12 ++++- 3 files changed, 67 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2564546..e1e9f90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,9 +168,17 @@ These are load-bearing. Each one was paid for. `gameManager.handlePacket` from the host thread races the packet pump inside `changePhase`. Readiness is client-originated, in the lounge only, and reads the bot's own phase rather than the server's. -- **Candidates stay capped.** Every performance estimate assumes ~20 curated - candidates per unit. Enumerating paths the way Princess does is 100-1000x and - puts a "thinking..." message back in front of the player. +- **Units report every scored candidate.** No unit prunes before it reports + upward. Any future cap is set by the force algorithm that needs one, not + chosen in advance by the unit. Measured before this replaced "candidates stay + capped": 16v16 unpruned is 2,416 candidates and about 198 KB, scoring the lot + is 41k multiply-adds, generating and scoring 198 stands against 3 enemies + costs 27 ms, the pathfinding half costs 29 us per unit, and the reachability + search runs 50-80x faster than MegaMek's own `ShortestPathFinder` and + `LongestPathFinder` on the same boards and starts. `ForceThinker::command` is + linear in candidates per unit, so a prune here bought nothing and could only + lose the one candidate the force needed. Cutting the list down for a *reader* + is a different job: `sds-core/src/surface.rs`. - **LOS gets cached per turn, shared across the side.** It is the dominant cost of any real positional feature, and eight units will ask overlapping questions. - **The tokio fan-out is for the barrier, not for speed.** A force must have diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index 07f244e..7437378 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -296,10 +296,14 @@ pub struct Params { /// 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. + /// The most stands the frontier instrument may hold. 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 list is never a silent one. Zero is no + /// bound. + /// + /// **Not a cap on what the unit reports.** Nothing here bounds the proposal + /// set: see [`Ranking::proposals`]. pub frontier_max: usize, } @@ -308,8 +312,8 @@ 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. + // How long the frontier instrument gets before it is thinned for + // reading. Not a bound on anything reported upward. frontier_max: 20, } } @@ -349,10 +353,11 @@ 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. + /// Stands on the Pareto frontier, before any bound is applied. An + /// instrument: the frontier is not the emission. pub frontier: u64, - /// Stands actually emitted. Below `frontier` when the bound thinned it, - /// and printed next to it wherever a frontier is reported. + /// Stands the frontier instrument kept. 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. @@ -364,7 +369,10 @@ pub struct StandStats { /// The scored stands, the frontier drawn through them, and the counters. /// -/// **The proposal set is the Pareto frontier over (deal high, take low).** +/// **The proposal set is every scored stand.** A unit does not prune before it +/// reports: see [`Self::proposals`]. The frontier below is an instrument. +/// +/// **The Pareto frontier over (deal high, take low)** is kept for reading. /// 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. @@ -395,12 +403,21 @@ pub struct StandStats { /// [`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. +/// +/// **Why the frontier stopped being the emission.** It was a prune, and the +/// measurements say a prune here is pure loss: 16v16 unpruned is 2,416 +/// candidates and about 198 KB, scoring every one of them is 41k +/// multiply-adds, and `ForceThinker::command` is linear in the candidates a +/// unit hands it. Nothing was being bought, and a candidate the force never +/// sees is the failure this epic exists to remove. Picking the few worth +/// showing a *person* is a different job with different criteria, and it lives +/// in [`crate::surface`]. #[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. + /// most `frontier_max` of them. An instrument, not the emission. pub frontier: Vec, /// Indices into [`Self::scored`], most dealt first, at most `top_k`. pub by_offence: Vec, @@ -421,14 +438,17 @@ impl Ranking { self.by_defence.iter().map(|at| &self.scored[*at]) } - /// The proposal set: the frontier, safest first. + /// The frontier, safest first. An instrument. pub fn on_frontier(&self) -> impl Iterator { self.frontier.iter().map(|at| &self.scored[*at]) } - /// The proposal set as indices into [`Self::scored`]. + /// The proposal set as indices into [`Self::scored`]: all of them. + /// + /// The unit reports everything it scored. See the note on this type for + /// why that is cheaper than the prune it replaced. pub fn proposals(&self) -> Vec { - self.frontier.clone() + (0..self.scored.len()).collect() } } @@ -773,14 +793,23 @@ pub fn score_stands( } } -/// The frontier as [`Proposal`]s, measured in the existing feature basis. +/// Every scored stand as a [`Proposal`], 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 +/// layer consumes [`Proposal`]s, and nothing joined them, so a scored set 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. /// +/// **All of them, unpruned.** This emitted the Pareto frontier until it was +/// measured: 16v16 unpruned is 2,416 candidates and about 198 KB, scoring the +/// lot is 41k multiply-adds, and `ForceThinker::command` loops each unit's +/// proposals and takes an argmax, so it is linear in what it is given. Pruning +/// at the unit bought nothing and could only lose the one candidate the force +/// needed. Cutting the list down for a *reader* is a separate job with separate +/// criteria: [`crate::surface`]. +/// /// **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`, @@ -811,7 +840,7 @@ pub fn propose( ) -> 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(); + let picks: Vec<&StandScore> = ranking.scored.iter().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. diff --git a/crates/sds-core/tests/stands_frontier.rs b/crates/sds-core/tests/stands_frontier.rs index b58c779..c05a13a 100644 --- a/crates/sds-core/tests/stands_frontier.rs +++ b/crates/sds-core/tests/stands_frontier.rs @@ -178,7 +178,8 @@ fn the_emitted_set_is_free_of_domination_on_every_board() { } } -/// The frontier becomes proposals in the basis the force already scores with. +/// Every scored stand becomes a proposal in the basis the force already scores +/// with, and none of them is pruned on the way. #[test] fn every_proposal_carries_the_basis_the_force_reads() { let fight = Fight::on("open"); @@ -195,7 +196,14 @@ fn every_proposal_carries_the_basis_the_force_reads() { &Params::default(), ); let proposals = propose(&los, &mover, &ranking, &foes, &[]); - assert_eq!(proposals.len(), ranking.frontier.len()); + // All of them. A unit does not prune before it reports: the force is + // linear in what it is handed, and the candidate it never sees is the one + // that cannot be recovered. + assert_eq!(proposals.len(), ranking.scored.len()); + assert!( + proposals.len() > ranking.frontier.len(), + "the board's frontier is the whole set, so this asserts nothing" + ); for proposal in &proposals { assert_eq!(proposal.unit, fight.our_unit.id); -- 2.51.2