diff --git a/crates/sds-bot/src/force.rs b/crates/sds-bot/src/force.rs index eb3e4b2..c3135d7 100644 --- a/crates/sds-bot/src/force.rs +++ b/crates/sds-bot/src/force.rs @@ -135,6 +135,12 @@ impl ForceThinker { // // Ties go to the earlier proposal, and a unit offers its proposals in a // fixed order, so nothing here depends on what finished first. + // + // One argmax per unit, coordinating with nothing: two units can pick + // the same hex and can both empty their guns into a machine one of them + // was going to finish. `sds_core::reconcile` is the replacement and is + // not wired in here yet - adopting it changes play, so it arrives with + // a control run. let chosen: Vec = thoughts .iter() .filter_map(|thought| { diff --git a/crates/sds-core/examples/stands.rs b/crates/sds-core/examples/stands.rs index 19fbc9e..5b778d7 100644 --- a/crates/sds-core/examples/stands.rs +++ b/crates/sds-core/examples/stands.rs @@ -656,7 +656,7 @@ fn report( // worth printing, each carrying the name of the intent that reached it. println!(); println!("surfaced for a reader, by intent (Tchebycheff over the stance vocabulary)"); - let facts = facts_of(&sweep); + let facts = facts_of(sweep); println!(" a weighted sum reaches only the convex hull; min-max reaches the concave middle"); println!(" the weight table is PLACEHOLDER-DOCTRINE-gallimaufry and is not a decision"); for (label, ranking) in rankings { diff --git a/crates/sds-core/src/lib.rs b/crates/sds-core/src/lib.rs index 833f2cf..e1a15dc 100644 --- a/crates/sds-core/src/lib.rs +++ b/crates/sds-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod hitloc; pub mod los; pub mod pathfind; pub mod plan; +pub mod reconcile; pub mod role; pub mod stance; pub mod stands; diff --git a/crates/sds-core/src/reconcile.rs b/crates/sds-core/src/reconcile.rs new file mode 100644 index 0000000..aca9689 --- /dev/null +++ b/crates/sds-core/src/reconcile.rs @@ -0,0 +1,844 @@ +//! Many menus in, one set of orders out. +//! +//! [`crate::stands::propose`] gives a force one menu per unit, every stand +//! scored and nothing pruned. What a force did with those menus was take an +//! argmax down each one independently, which is Princess with extra steps: two +//! units cannot agree to finish one target, cannot avoid walking into the same +//! hex, and cannot decide that one of them should go elsewhere because the +//! other has the shot. This module is the step that makes those three +//! possible. +//! +//! **Sequential greedy with max regret, not a joint search.** The joint +//! assignment is the product of the menus - 2 units at 150 candidates is +//! 22,500 and 16 units is `150^16`, which is 10^34 - so nothing here +//! enumerates it. Each round every unassigned unit reads its own menu once +//! against the orders already given, and the unit with the most to lose by +//! going later is served first: its *regret*, the gap between its best option +//! and its next best. That is the auction rule in its simplest form, and the +//! reason to prefer it over plain unit order is that a unit with one usable +//! stand should not lose it to a unit that had five. +//! +//! The cost is `units^2` menu reads, and one menu read is linear in that +//! unit's candidates: [`Stats::evaluations`] counts them so the claim is a +//! measurement rather than an assertion. +//! +//! **What makes coordination worth anything is that the force's value is not +//! the sum of the units' values.** A unit's own value is its dot product and +//! nothing else - see [`Proposal::value`] - and if that were the whole story +//! there would be nothing to reconcile beyond the hexes. The coupling is on +//! the target: damage past what a machine has left is spent on nothing, and +//! damage that finishes one is worth more than the same points spread over two +//! that both keep shooting. [`worth`] states that as a number, and it is the +//! measure a reconciliation is compared against an independent argmax on. +//! +//! **Two of ours never take the same hex.** A hex an order has claimed is +//! unavailable to every later order, and a unit whose whole menu is claimed is +//! recorded in [`Reconciliation::stalled`] rather than dropped. +//! +//! **Nothing is thrown away.** An [`Order`] is an index into the menu it came +//! from, so every proposal is still in the record beside the one taken - +//! including which one the unit would have taken alone, in [`Order::alone`]. +//! "The lance chose badly" and "no unit offered anything better" have to stay +//! distinguishable, which is the same rule [`crate::plan`] states. +//! +//! **Order in, order out.** Menus are read in unit id order and every +//! reduction is over a `BTreeMap`, so a force that hears from its units in a +//! different sequence gets the same orders. Nothing here reads a clock. +//! +//! Nothing in `sds-bot` calls this yet: `ForceThinker::command` still takes an +//! independent argmax, play is unchanged, and no control run applies. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::features::Weights; +use crate::plan::{Proposal, UnitThought}; +use crate::wire::{Coord, Unit}; + +/// How a force prices what happens to a target, as against what happens to one +/// of its units. +/// +/// **PLACEHOLDER-DOCTRINE-gallimaufry.** That overkill is waste and that a kill +/// is worth more than the damage it took is not controversial; the exchange +/// rate between those and a proposal's own dot product is doctrine, and nobody +/// has argued over these two numbers. Grep the marker for the other places +/// standing on the same footing. The reconciliation rule does not depend on the +/// magnitudes - it depends on the target term being nonlinear at all. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Doctrine { + /// What it is worth to have removed all of a target's remaining health, + /// counted as a share of that health. Damage beyond it counts nothing, + /// which is what makes piling a second alpha onto a corpse a loss rather + /// than a wash. + pub effective: f32, + /// The bonus for the damage that actually finishes a machine. This is the + /// term that makes two units ganging up beat two units each hurting + /// somebody: a target that stops shooting is worth more than the sum of + /// the points it took. + pub finish: f32, +} + +impl Default for Doctrine { + fn default() -> Self { + Self { + effective: 1.0, + finish: 0.5, + } + } +} + +impl Doctrine { + /// What a target being on `damage` of its `health` is worth to the force. + /// + /// Health-normalised, so the term means the same against a Locust and an + /// Atlas and stays the same size as a dot product over normalised + /// features. Non-positive health is a machine already gone: nothing more + /// can be got out of it. + fn term(&self, health: f32, damage: f32) -> f32 { + if health <= 0.0 { + return 0.0; + } + let effective = damage.clamp(0.0, health); + let mut worth = self.effective * effective / health; + if damage >= health { + worth += self.finish; + } + worth + } +} + +/// What each enemy has left, by unit id: armour plus structure. +/// +/// The denominator every target term is normalised by. A machine already at +/// zero is left in the map at zero, which reads as "nothing more to get". +pub fn remaining(enemies: &[&Unit]) -> BTreeMap { + enemies + .iter() + .map(|unit| (unit.id, (unit.armor + unit.internal).max(0) as f32)) + .collect() +} + +/// One unit's order, and what it cost the unit to take it. +#[derive(Debug, Clone, PartialEq)] +pub struct Order { + pub unit: i32, + /// Index into that unit's own proposals. The menu is unchanged and every + /// other entry is still readable beside this one. + pub proposal: usize, + pub label: String, + /// Where this leaves the unit, and the hex no later order may claim. + pub end: Option, + /// The proposal's own dot product, unchanged by anything here. + pub value: f32, + /// What the force gained by taking it: the dot product plus the change in + /// the target terms, measured against the orders already given. + pub marginal: f32, + /// The proposal this unit would have taken on its own. Equal to + /// [`Self::proposal`] whenever coordination cost this unit nothing. + pub alone: usize, + /// The gap to this unit's next best option when it was served. Infinite + /// when it had only one option left, which is what put it first. + pub regret: f32, + /// Which round of the auction served this unit. 0 is the unit that had the + /// most to lose by waiting. + pub round: usize, +} + +impl Order { + /// Whether the force moved this unit off the pick it would have made + /// alone. + pub fn coordinated(&self) -> bool { + self.proposal != self.alone + } +} + +/// What a reconciliation did, in counters. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Stats { + /// Menus in. + pub units: u64, + /// Proposals in, over every menu. None of them left the record. + pub considered: u64, + /// Proposals scored. The cost, in work units rather than wall clock. + pub evaluations: u64, + /// Proposals skipped because another unit had claimed the hex. + pub blocked: u64, + /// Orders that differ from what the unit would have taken alone. + pub moved: u64, +} + +/// One coherent set of orders, and everything it did not take. +#[derive(Debug, Clone, PartialEq)] +pub struct Reconciliation { + /// One per unit that got an order, in unit id order. + pub orders: Vec, + /// Units whose every proposal stood in a hex somebody else had claimed. + /// Recorded rather than given a hex they cannot have. + pub stalled: Vec, + pub stats: Stats, +} + +impl Reconciliation { + /// `(unit, proposal index)`, in unit id order: the assignment, as [`worth`] + /// wants it. + pub fn picks(&self) -> Vec<(i32, usize)> { + self.orders + .iter() + .map(|order| (order.unit, order.proposal)) + .collect() + } + + /// `(shooter, target)` pairs, for + /// [`crate::features::force::JointAssignment`]. + /// + /// The target is the one each order does most damage to, ties to the lower + /// id. An order that expects to damage nobody contributes no pair rather + /// than a guess. + pub fn pairs(&self, thoughts: &[UnitThought]) -> Vec<(i32, i32)> { + let mut pairs = Vec::new(); + for order in &self.orders { + let Some(proposal) = proposal_of(thoughts, order.unit, order.proposal) else { + continue; + }; + let mut best: Option<(i32, f32)> = None; + for (target, damage) in &proposal.damage_by_target { + if best.is_none_or(|(_, held)| *damage > held) { + best = Some((*target, *damage)); + } + } + if let Some((target, damage)) = best { + if damage > 0.0 { + pairs.push((order.unit, target)); + } + } + } + pairs + } +} + +fn proposal_of(thoughts: &[UnitThought], unit: i32, at: usize) -> Option<&Proposal> { + thoughts + .iter() + .find(|thought| thought.unit == unit)? + .proposals + .get(at) +} + +/// What one assignment is worth to the force. +/// +/// The measure a coordinated answer is compared against an independent argmax +/// on, and the only place the two halves are added: the units' own dot +/// products, and what the assignment does to the targets under [`Doctrine`]. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Worth { + /// The units' own values, summed. This is the number an independent argmax + /// maximises, and on its own it is why an independent argmax overkills. + pub own: f32, + /// Damage that lands inside a target's remaining health. + pub effective: f32, + /// Damage aimed past it. Spent on nothing. + pub wasted: f32, + /// Targets the assignment expects to finish. + pub finished: usize, + /// `own` plus the target terms: the objective the greedy maximises. + pub total: f32, +} + +/// Score an assignment of `(unit, proposal index)` pairs. +/// +/// Damage against an enemy the caller did not describe is left out of the +/// target terms entirely rather than assumed harmless: an unknown denominator +/// is not a small one. +pub fn worth( + picks: &[(i32, usize)], + thoughts: &[UnitThought], + remaining: &BTreeMap, + weights: &Weights, + doctrine: &Doctrine, +) -> Worth { + let mut worth = Worth::default(); + let mut dealt: BTreeMap = BTreeMap::new(); + for (unit, at) in picks { + let Some(proposal) = proposal_of(thoughts, *unit, *at) else { + continue; + }; + worth.own += proposal.value(weights); + for (target, damage) in &proposal.damage_by_target { + *dealt.entry(*target).or_insert(0.0) += *damage; + } + } + for (target, damage) in &dealt { + let Some(health) = remaining.get(target) else { + continue; + }; + let effective = damage.clamp(0.0, *health); + worth.effective += effective; + worth.wasted += damage - effective; + if *damage >= *health && *health > 0.0 { + worth.finished += 1; + } + worth.total += doctrine.term(*health, *damage); + } + worth.total += worth.own; + worth +} + +/// Each unit's own argmax, coordinating with nobody. +/// +/// The baseline, kept here so the comparison is one call and cannot drift from +/// what the force used to do. Ties go to the earlier proposal. +pub fn independent(thoughts: &[UnitThought], weights: &Weights) -> Vec<(i32, usize)> { + let mut picks: Vec<(i32, usize)> = thoughts + .iter() + .filter_map(|thought| Some((thought.unit, best_alone(thought, weights)?))) + .collect(); + picks.sort_unstable(); + picks +} + +fn best_alone(thought: &UnitThought, weights: &Weights) -> Option { + let mut best: Option<(usize, f32)> = None; + for (at, proposal) in thought.proposals.iter().enumerate() { + let value = proposal.value(weights); + if best.is_none_or(|(_, held)| value > held) { + best = Some((at, value)); + } + } + best.map(|(at, _)| at) +} + +/// What one unit would bid this round. +struct Bid { + at: usize, + marginal: f32, + regret: f32, +} + +/// Turn every unit's menu into one set of orders. +/// +/// `remaining` is what each enemy has left - [`remaining`] reads it off the +/// units - and is what the target terms are normalised by. +pub fn reconcile( + thoughts: &[UnitThought], + remaining: &BTreeMap, + weights: &Weights, + doctrine: &Doctrine, +) -> Reconciliation { + // Unit id order, so the sequence the force heard from its units in cannot + // reach the answer. Stable, so two menus with one id would at least be + // read the same way twice; ids are unique in every caller. + let mut menus: Vec<&UnitThought> = thoughts.iter().collect(); + menus.sort_by_key(|thought| thought.unit); + + let mut stats = Stats { + units: menus.len() as u64, + considered: menus.iter().map(|m| m.proposals.len() as u64).sum(), + ..Stats::default() + }; + let alone: Vec> = menus + .iter() + .map(|thought| best_alone(thought, weights)) + .collect(); + + let mut pending: Vec = (0..menus.len()).collect(); + let mut taken: BTreeSet = BTreeSet::new(); + let mut dealt: BTreeMap = BTreeMap::new(); + let mut orders: Vec = Vec::new(); + let mut stalled: Vec = Vec::new(); + let mut round = 0usize; + + while !pending.is_empty() { + let mut choice: Option<(usize, Bid)> = None; + let mut done: Vec = Vec::new(); + for (position, menu) in pending.iter().copied().enumerate() { + match bid( + menus[menu], + &taken, + &dealt, + remaining, + weights, + doctrine, + &mut stats, + ) { + // Strictly greater, and `pending` is in unit id order, so a tie + // on regret goes to the lower id rather than to whoever was + // read first. + Some(bid) => { + if choice + .as_ref() + .is_none_or(|(_, held)| bid.regret > held.regret) + { + choice = Some((position, bid)); + } + } + // Every hex it could reach is claimed, and hexes are only ever + // claimed, so waiting cannot help it. + None => { + done.push(position); + stalled.push(menus[menu].unit); + } + } + } + + let Some((position, bid)) = choice else { + break; + }; + let menu = pending[position]; + let thought = menus[menu]; + let proposal = &thought.proposals[bid.at]; + if let Some(end) = proposal.end { + taken.insert(end); + } + for (target, damage) in &proposal.damage_by_target { + *dealt.entry(*target).or_insert(0.0) += *damage; + } + let alone = alone[menu].unwrap_or(bid.at); + if alone != bid.at { + stats.moved += 1; + } + orders.push(Order { + unit: thought.unit, + proposal: bid.at, + label: proposal.label.clone(), + end: proposal.end, + value: proposal.value(weights), + marginal: bid.marginal, + alone, + regret: bid.regret, + round, + }); + done.push(position); + done.sort_unstable(); + for position in done.into_iter().rev() { + pending.remove(position); + } + round += 1; + } + + orders.sort_by_key(|order| order.unit); + stalled.sort_unstable(); + Reconciliation { + orders, + stalled, + stats, + } +} + +/// One unit's best remaining option, and what it would give up by waiting. +/// +/// `None` when every proposal it has stands in a claimed hex. +fn bid( + thought: &UnitThought, + taken: &BTreeSet, + dealt: &BTreeMap, + remaining: &BTreeMap, + weights: &Weights, + doctrine: &Doctrine, + stats: &mut Stats, +) -> Option { + let mut best: Option<(usize, f32)> = None; + let mut second = f32::NEG_INFINITY; + for (at, proposal) in thought.proposals.iter().enumerate() { + if proposal.end.is_some_and(|end| taken.contains(&end)) { + stats.blocked += 1; + continue; + } + stats.evaluations += 1; + let marginal = marginal(proposal, dealt, remaining, weights, doctrine); + match best { + Some((_, held)) if marginal <= held => second = second.max(marginal), + Some((_, held)) => { + second = second.max(held); + best = Some((at, marginal)); + } + None => best = Some((at, marginal)), + } + } + let (at, marginal) = best?; + Some(Bid { + at, + marginal, + // A unit with one option left has everything to lose by waiting, and + // that is what puts it at the front of the queue. + regret: if second.is_finite() { + marginal - second + } else { + f32::INFINITY + }, + }) +} + +/// What taking this proposal now would add to the force's worth. +/// +/// The unit's own value plus the change in the target terms it moves. Summed +/// over a `BTreeMap`, so the order the targets are added in is fixed. +fn marginal( + proposal: &Proposal, + dealt: &BTreeMap, + remaining: &BTreeMap, + weights: &Weights, + doctrine: &Doctrine, +) -> f32 { + let mut gain = proposal.value(weights); + for (target, damage) in &proposal.damage_by_target { + let Some(health) = remaining.get(target) else { + continue; + }; + let before = dealt.get(target).copied().unwrap_or(0.0); + gain += doctrine.term(*health, before + damage) - doctrine.term(*health, before); + } + gain +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::features::{firing, Score}; + use crate::plan::Proposal; + use crate::stance::Appraisal; + use crate::wire::Action; + + /// One weight on one feature, so a proposal's own value is whatever the + /// fixture says it is and the coordination terms are the only other thing + /// in the arithmetic. + fn weights() -> Weights { + Weights::default().with::(1.0) + } + + /// A proposal worth `value` on its own, ending on `end`, doing `damage` to + /// the enemies named. + fn offer( + unit: i32, + label: &str, + end: Option, + value: f32, + damage: &[(i32, f32)], + ) -> Proposal { + let mut features = crate::features::FeatureVector::new(); + features.record::(Score::probability(value)); + let by_target: BTreeMap = damage.iter().copied().collect(); + Proposal { + unit, + label: label.into(), + action: Action::Move { steps: vec![] }, + end, + features, + damage_dealt: by_target.values().copied().fold(0.0, f32::max), + damage_by_target: by_target, + damage_taken: 0.0, + risk: 0.0, + notes: vec![], + } + } + + fn menu(unit: i32, proposals: Vec) -> UnitThought { + UnitThought { + unit, + role: "BRAWLER".into(), + appraisal: Appraisal::default(), + proposals, + role_conflict: None, + } + } + + fn hex(x: i32, y: i32) -> Option { + Some(Coord::new(x, y)) + } + + /// Two units whose best stand is the same hex. One of them has to take + /// something else, and which one is the auction's business. + #[test] + fn two_of_ours_never_take_one_hex() { + let thoughts = vec![ + menu( + 1, + vec![ + offer(1, "the ridge", hex(4, 4), 0.9, &[(11, 10.0)]), + offer(1, "beside it", hex(4, 5), 0.5, &[(11, 6.0)]), + ], + ), + menu( + 2, + vec![ + offer(2, "the ridge", hex(4, 4), 0.8, &[(11, 10.0)]), + offer(2, "the treeline", hex(6, 5), 0.4, &[(11, 5.0)]), + ], + ), + ]; + let health = BTreeMap::from([(11, 40.0)]); + let orders = reconcile(&thoughts, &health, &weights(), &Doctrine::default()); + + let hexes: BTreeSet = orders.orders.iter().filter_map(|o| o.end).collect(); + assert_eq!(hexes.len(), orders.orders.len(), "two orders share a hex"); + assert_eq!(orders.orders.len(), 2); + assert!( + orders.stats.blocked > 0, + "the claimed hex was never refused" + ); + } + + /// The case the whole module is for, part one: an independent argmax puts + /// both units onto a target that only one of them can finish, and the + /// second alpha lands on a machine that is already gone. + #[test] + fn coordination_beats_two_argmaxes_by_not_overkilling() { + // E11 has 10 left; E12 is fresh. Both units would rather shoot the + // cripple, and both shooting it is 10 points on the floor. + let health = BTreeMap::from([(11, 10.0), (12, 40.0)]); + let thoughts = vec![ + menu( + 1, + vec![ + offer(1, "finish 11", hex(4, 4), 0.90, &[(11, 12.0)]), + offer(1, "open on 12", hex(4, 6), 0.80, &[(12, 11.0)]), + ], + ), + menu( + 2, + vec![ + offer(2, "finish 11", hex(5, 4), 0.88, &[(11, 12.0)]), + offer(2, "open on 12", hex(5, 6), 0.78, &[(12, 11.0)]), + ], + ), + ]; + let (weights, doctrine) = (weights(), Doctrine::default()); + let alone = independent(&thoughts, &weights); + let orders = reconcile(&thoughts, &health, &weights, &doctrine); + + assert_eq!( + alone, + vec![(1, 0), (2, 0)], + "both argmaxes take the cripple" + ); + assert_ne!(orders.picks(), alone, "the reconciliation changed nothing"); + assert_eq!( + orders.stats.moved, 1, + "exactly one unit should be moved off" + ); + + let before = worth(&alone, &thoughts, &health, &weights, &doctrine); + let after = worth(&orders.picks(), &thoughts, &health, &weights, &doctrine); + assert!( + after.total > before.total, + "coordinated {after:?} did not beat independent {before:?}" + ); + assert!(after.effective > before.effective); + assert!(after.wasted < before.wasted); + } + + /// Part two: neither unit can finish a target alone, so an independent + /// argmax hurts two machines and kills nothing. Ganging up is worth more + /// than the points say, and the finish term is where that is written down. + #[test] + fn coordination_gangs_up_to_finish_a_target() { + let health = BTreeMap::from([(11, 20.0), (12, 40.0)]); + let thoughts = vec![ + menu( + 1, + vec![ + offer(1, "into 11", hex(4, 4), 0.90, &[(11, 12.0)]), + offer(1, "into 12", hex(4, 6), 0.60, &[(12, 11.0)]), + ], + ), + menu( + 2, + vec![ + // On its own this unit would rather shoot the fresh one. + offer(2, "into 12", hex(5, 6), 0.95, &[(12, 11.0)]), + offer(2, "into 11", hex(5, 4), 0.90, &[(11, 12.0)]), + ], + ), + ]; + let (weights, doctrine) = (weights(), Doctrine::default()); + let alone = independent(&thoughts, &weights); + let orders = reconcile(&thoughts, &health, &weights, &doctrine); + + assert_eq!(alone, vec![(1, 0), (2, 0)], "the two argmaxes split up"); + assert_eq!( + orders.picks(), + vec![(1, 0), (2, 1)], + "both units should end up on 11" + ); + let before = worth(&alone, &thoughts, &health, &weights, &doctrine); + let after = worth(&orders.picks(), &thoughts, &health, &weights, &doctrine); + assert_eq!(before.finished, 0, "neither unit can finish 11 alone"); + assert_eq!(after.finished, 1, "together they should finish 11"); + assert!( + after.total > before.total, + "coordinated {after:?} did not beat independent {before:?}" + ); + } + + /// The rejected proposals are the record. Nothing here consumes a menu, and + /// an order is an index back into one. + #[test] + fn every_proposal_is_still_in_the_record() { + let thoughts = vec![ + menu( + 1, + vec![ + offer(1, "a", hex(4, 4), 0.9, &[(11, 10.0)]), + offer(1, "b", hex(4, 5), 0.5, &[(11, 6.0)]), + offer(1, "c", hex(4, 6), 0.1, &[]), + ], + ), + menu(2, vec![offer(2, "d", hex(5, 4), 0.8, &[(11, 9.0)])]), + ]; + let health = BTreeMap::from([(11, 40.0)]); + let orders = reconcile(&thoughts, &health, &weights(), &Doctrine::default()); + + assert_eq!(orders.stats.considered, 4); + let kept: usize = thoughts.iter().map(|t| t.proposals.len()).sum(); + assert_eq!(kept, 4, "the menus were consumed"); + for order in &orders.orders { + let thought = thoughts.iter().find(|t| t.unit == order.unit).unwrap(); + assert!(order.proposal < thought.proposals.len()); + } + // The ones not taken are still readable beside the one that was. + let first = &thoughts[0]; + let taken = orders.orders[0].proposal; + for (at, proposal) in first.proposals.iter().enumerate() { + assert!(!proposal.label.is_empty()); + if at != taken { + assert!(orders.orders.iter().all(|o| o.label != proposal.label)); + } + } + } + + /// A CLAUDE.md invariant: results must not depend on completion order. + #[test] + fn the_orders_do_not_depend_on_the_order_the_menus_arrived_in() { + let build = |id: i32, seed: f32| { + menu( + id, + (0..6) + .map(|k| { + offer( + id, + &format!("{id}-{k}"), + hex(k, id), + (seed + k as f32 * 0.07) % 1.0, + &[(11, 4.0 + k as f32), (12, 3.0)], + ) + }) + .collect(), + ) + }; + let health = BTreeMap::from([(11, 22.0), (12, 30.0)]); + let forward = vec![build(1, 0.3), build(2, 0.31), build(3, 0.2)]; + let mut backward = forward.clone(); + backward.reverse(); + + let one = reconcile(&forward, &health, &weights(), &Doctrine::default()); + let two = reconcile(&backward, &health, &weights(), &Doctrine::default()); + assert_eq!(one.picks(), two.picks()); + assert_eq!(one.orders, two.orders); + assert_eq!(one.stats, two.stats); + } + + /// The cost, as a count rather than a claim: doubling a unit's candidates + /// doubles the work, and the units multiply it by how many of them there + /// are rather than by each other's menu sizes. + #[test] + fn the_work_is_linear_in_candidates_per_unit() { + let health = BTreeMap::from([(11, 60.0)]); + let sweep = |candidates: i32| { + let thoughts: Vec = (1..=4) + .map(|id| { + menu( + id, + (0..candidates) + .map(|k| { + offer( + id, + "stand", + hex(k, id), + (k as f32 * 0.013) % 1.0, + &[(11, 1.0 + k as f32 * 0.1)], + ) + }) + .collect(), + ) + }) + .collect(); + reconcile(&thoughts, &health, &weights(), &Doctrine::default()) + .stats + .evaluations + }; + let (small, large) = (sweep(50), sweep(100)); + // 4 units, 4 rounds, one menu read each: 10 reads at 50 and at 100. + assert_eq!(small, 500); + assert_eq!(large, 1000); + assert_eq!(large, small * 2, "{large} against {small} is not linear"); + } + + /// A unit with nowhere left to stand is reported, not given a hex somebody + /// else is in. + #[test] + fn a_unit_with_nowhere_to_go_is_recorded() { + let thoughts = vec![ + menu( + 1, + vec![offer(1, "the only hex", hex(4, 4), 0.9, &[(11, 10.0)])], + ), + menu( + 2, + vec![offer(2, "the same hex", hex(4, 4), 0.8, &[(11, 9.0)])], + ), + ]; + let health = BTreeMap::from([(11, 40.0)]); + let orders = reconcile(&thoughts, &health, &weights(), &Doctrine::default()); + assert_eq!(orders.orders.len(), 1); + assert_eq!(orders.stalled, vec![2]); + } + + /// A proposal that is not a move claims no hex, so two of them can stand + /// together. + #[test] + fn a_proposal_with_no_end_claims_nothing() { + let thoughts = vec![ + menu(1, vec![offer(1, "hold fire", None, 0.9, &[])]), + menu(2, vec![offer(2, "hold fire", None, 0.8, &[])]), + ]; + let orders = reconcile( + &thoughts, + &BTreeMap::new(), + &weights(), + &Doctrine::default(), + ); + assert_eq!(orders.orders.len(), 2); + assert!(orders.stalled.is_empty()); + } + + /// Overkill is priced as waste and a finish is priced above the points it + /// took. Both directions of the target term, on one fixture. + #[test] + fn the_target_term_pays_for_a_kill_and_not_for_overkill() { + let doctrine = Doctrine::default(); + assert!((doctrine.term(20.0, 10.0) - 0.5).abs() < 1e-6); + assert!((doctrine.term(20.0, 20.0) - 1.5).abs() < 1e-6); + // Past the kill nothing more is bought. + assert!((doctrine.term(20.0, 60.0) - doctrine.term(20.0, 20.0)).abs() < 1e-6); + assert_eq!(doctrine.term(0.0, 5.0), 0.0); + } + + /// The assignment is what the force-level features are measured over. + #[test] + fn the_orders_read_as_a_joint_assignment() { + let thoughts = vec![ + menu( + 1, + vec![offer( + 1, + "into 11", + hex(4, 4), + 0.9, + &[(11, 10.0), (12, 2.0)], + )], + ), + menu(2, vec![offer(2, "into 12", hex(5, 4), 0.8, &[(12, 9.0)])]), + ]; + let health = BTreeMap::from([(11, 40.0), (12, 40.0)]); + let orders = reconcile(&thoughts, &health, &weights(), &Doctrine::default()); + assert_eq!(orders.pairs(&thoughts), vec![(1, 11), (2, 12)]); + } +} diff --git a/plan/hierarchy.md b/plan/hierarchy.md index a351d98..1980de0 100644 --- a/plan/hierarchy.md +++ b/plan/hierarchy.md @@ -19,6 +19,10 @@ Force structure comes from MegaMek's own `Forces` tree; roles from `UnitRole`. Built already: two-level planning, the barrier, stance/grit, node transports (`local:`, `proc:`, `tcp:`) so a level can move to another machine. +- [x] Reconcile the menus instead of taking an argmax down each one: + `sds-core/src/reconcile.rs`. Sequential greedy with max regret, one hex + to one unit, every proposal still in the record. See "Reconciliation" + below - [ ] Company level does something with more than one lance beyond passing a stance down - [ ] Two-pass planning: cheap appraisal first so the force can pick a stance, @@ -29,3 +33,64 @@ Built already: two-level planning, the barrier, stance/grit, node transports proposals and a lance-chosen shape - [ ] Determinism: fixed-order reduction, budgets in work units not wall-clock, per-unit seeded RNG streams, no clock reads in decision logic + +## Reconciliation + +A force used to loop each unit's menu and take an argmax down it. Nothing +coordinated: two units could not agree to finish one target, could not avoid +walking into the same hex, and could not decide that one of them should go +elsewhere because the other had the shot. + +**Sequential greedy with max regret.** The joint assignment is the product of +the menus - 16 units at 150 candidates is 10^34 - so nothing enumerates it. +Each round every unassigned unit reads its own menu once against the orders +already given, and the unit with the largest gap between its best option and +its next best is served first. A hex an order claims is unavailable to every +later order, and a unit whose whole menu is claimed is recorded as stalled +rather than given a hex it cannot have. Menus are read in unit id order and +every reduction is over a `BTreeMap`, so the sequence a force hears from its +units in cannot reach the answer. + +**What makes coordination worth anything is that the force's value is not the +sum of the units' values.** A proposal's own value is its dot product and +nothing else, and if that were all of it there would be nothing to reconcile +beyond the hexes. The coupling is on the target: damage past what a machine has +left is spent on nothing, and damage that finishes one is worth more than the +same points spread over two that both keep shooting. `reconcile::worth` states +that as a number, and it is the measure the reconciliation is compared against +an independent argmax on. The two coefficients are +`PLACEHOLDER-DOCTRINE-gallimaufry`: that overkill is waste and a kill is worth +a premium is not controversial, the exchange rate against a dot product is +doctrine nobody has argued over yet. + +**Coordination beats the sum of independent choices on two constructed cases.** +Both units' argmax is the cripple only one of them can finish, and the second +alpha lands on a machine that is already gone; and neither unit can finish a +target alone, so two argmaxes hurt two machines and kill nothing where ganging +up kills one. Both are in the module's tests, compared on `worth`. + +**On the real scene it is thinner than that, and worth saying plainly.** Over +the examples' 2v2 at `p = 1`, on `open` the reconciled answer differs from the +independent argmax and is worth more - 10.19 against 10.11, with 34.9 points of +effective damage against 12.3 - but what forced the change was the *hex*: both +units' argmax was the same stand, so the independent pair was not a legal joint +move at all. The target terms chose the replacement rather than the change. At +`p -> -inf` the damage columns collapse to zero almost everywhere, so the +target terms discriminate nothing and only the hex rule bites. A fixture where +concentration pays on its own merits is what the flanking demonstration is for. + +**Cost, measured on the three corpus boards at release**, 2 units a side: + +| board | menus | evaluations | reconcile | generating the menus | +|---|---|---|---|---| +| open | 194, 198 | 584 | 0.37 ms | 55 ms | +| forest | 40, 46 | 120 | 0.08 ms | 5 ms | +| water | 95, 121 | 305 | 0.20 ms | 14 ms | + +`units^2` menu reads, each linear in that unit's candidates: 4 units at 50 and +at 100 candidates evaluate 500 and 1,000 proposals, which a test pins. About +0.6 us a proposal, against 0.27 ms a proposal to generate one - the +reconciliation is under 1% of the turn. + +**Nothing in `sds-bot` calls it.** `ForceThinker::command` still takes an +independent argmax, play is unchanged, and no control run applies.