From 1da6aad010858bb7928bcda20bb9134a79737880 Mon Sep 17 00:00:00 2001 From: "did:web:projective-bichuwa.test.pds.did.bot" Date: Tue, 27 Oct 2026 18:06:01 +0000 Subject: [PATCH] refactor(hierarchy): price the focus as a weight, not a doctrine term Threads the coordinator's focus into each unit's proposal, where `focus_damage` is measured and scored with the ordinary weights. Removes the hardcoded Doctrine focus bonus so the column is not priced twice. The focus is chosen before the units measure, so a menu is never a round stale. Co-Authored-By: Claude Code Change-Id: I8d3d42ef7301ed4ffc4a822f5cfc7f9037aa3e69 --- crates/sds-bot/src/candidates.rs | 15 ++++++- crates/sds-bot/src/force.rs | 26 +----------- crates/sds-bot/src/main.rs | 30 ++++++++------ crates/sds-bot/src/unit.rs | 65 +++++++++++++++++++++++++++-- crates/sds-core/src/reconcile.rs | 70 -------------------------------- crates/sds-core/src/stands.rs | 34 ++++++++++++++++ crates/sds-node/src/lib.rs | 44 ++++---------------- plan/hierarchy.md | 9 ++-- 8 files changed, 140 insertions(+), 153 deletions(-) diff --git a/crates/sds-bot/src/candidates.rs b/crates/sds-bot/src/candidates.rs index 056c77f7..45e73f3a 100644 --- a/crates/sds-bot/src/candidates.rs +++ b/crates/sds-bot/src/candidates.rs @@ -188,6 +188,19 @@ pub fn generate( me: &Unit, weights: &Weights, memos: Option<&Memos>, +) -> Option { + generate_with_focus(observation, options, los, me, weights, memos, None) +} + +/// The same menu, with the company's focus measured into every proposal. +pub fn generate_with_focus( + observation: &Observation, + options: &OptionsCache, + los: &LosCache, + me: &Unit, + weights: &Weights, + memos: Option<&Memos>, + focus: Option, ) -> Option { let enemies: Vec<&Unit> = observation .units @@ -434,7 +447,7 @@ pub fn generate( // Every stand was scored; `stands::propose` measures all of them. Only the // surfaced ones are carried up, in the order the vectors were run, each one // once. - let measured = stands::propose(los, &mover, &sweep, &scored, &foes, &friends); + let measured = stands::propose_with_focus(los, &mover, &sweep, &scored, &foes, &friends, focus); let mut seen: BTreeSet = BTreeSet::new(); let mut out: Vec = Vec::new(); // Every scored stand goes up, not just the surfaced ones. The unit does not diff --git a/crates/sds-bot/src/force.rs b/crates/sds-bot/src/force.rs index fd7879c5..26cb4537 100644 --- a/crates/sds-bot/src/force.rs +++ b/crates/sds-bot/src/force.rs @@ -61,8 +61,6 @@ pub struct Ordered { /// Duties the coordinator has put on this force, with the force that /// raised each one. Empty when nothing harmonised. pub relations: Vec, - /// The enemy the company has chosen to finish this round. - pub focus: Option, } pub struct Sides<'a> { @@ -187,7 +185,6 @@ impl Thinker for ForceThinker { proposal, objective, relations, - focus, } => Ok(Response::Decision(Box::new(self.command( Ordered { tactic, @@ -195,7 +192,6 @@ impl Thinker for ForceThinker { proposal, objective, relations, - focus, }, force, thoughts, @@ -569,18 +565,11 @@ impl ForceThinker { // A spotter scores with the `spot` profile in place of the force's // own: Bombard's `los_in -6.0` would otherwise push the one unit that // has to see into the cover where it cannot. - // The company's focus reaches the reconciliation as a target term, so - // a lance prefers the machine the whole side is finishing when its own - // menu is close. - let doctrine = Doctrine { - focus: ordered.focus, - ..Doctrine::default() - }; let orders = reconcile_exploring_from( &thoughts, sides.remaining, Scoring::overriding(&weights, &profiles), - &doctrine, + &Doctrine::default(), &self.explore, round.max(0) as u32, claimed, @@ -932,7 +921,6 @@ mod tests { because: "2nd Lance".into(), duty: Duty::Spot, }], - focus: None, }, "1st Lance".into(), vec![thought(1, &[0.0])], @@ -977,7 +965,6 @@ mod tests { duty: Duty::Spot, }, ], - focus: None, }, "1st Lance".into(), vec![thought(1, &[0.0]), thought(2, &[0.0])], @@ -1011,7 +998,6 @@ mod tests { because: "2nd Lance".into(), duty: Duty::Screen, }], - focus: None, }, "1st Lance".into(), vec![thought(1, &[0.0]), thought(2, &[0.0])], @@ -1042,7 +1028,6 @@ mod tests { proposal: None, objective: None, relations: Vec::new(), - focus: None, }, "1st Lance".into(), vec![thought(1, &[0.0])], @@ -1073,7 +1058,6 @@ mod tests { because: "1st Lance".into(), duty: Duty::Spot, }], - focus: None, }, "1st Lance".into(), vec![thought(1, &[0.0])], @@ -1107,7 +1091,6 @@ mod tests { proposal: None, objective: None, relations: Vec::new(), - focus: None, }, force.into(), vec![thought(unit, &[0.0])], @@ -1262,7 +1245,6 @@ mod tests { proposal: Some(considered.proposal.clone()), objective: None, relations: Vec::new(), - focus: None, }, "1st Lance".into(), vec![thought(1, &[0.0])], @@ -1350,7 +1332,6 @@ mod tests { proposal: None, objective: Some(lost.clone()), relations: Vec::new(), - focus: None, }, "1st Lance".into(), vec![menu()], @@ -1469,7 +1450,6 @@ mod tests { proposal: None, objective: None, relations: Vec::new(), - focus: None, }, "1st Lance".into(), vec![menu()], @@ -1568,7 +1548,6 @@ mod tests { proposal: None, objective: None, relations: Vec::new(), - focus: None, }, "1st Lance".into(), vec![menu()], @@ -1636,7 +1615,6 @@ mod tests { proposal: None, objective: None, relations: Vec::new(), - focus: None, }, "1st Lance".into(), vec![thought(1, &[0.0])], @@ -1680,7 +1658,6 @@ mod tests { proposal: Some(considered.proposal.clone()), objective: None, relations: Vec::new(), - focus: None, }, "1st Lance".into(), vec![thought(1, &[0.0])], @@ -1931,7 +1908,6 @@ mod tests { proposal: None, objective: Some(state.clone()), relations: Vec::new(), - focus: None, }, "1st Lance".into(), vec![thought(1, &[0.0])], diff --git a/crates/sds-bot/src/main.rs b/crates/sds-bot/src/main.rs index 9adac54e..5f90d24b 100644 --- a/crates/sds-bot/src/main.rs +++ b/crates/sds-bot/src/main.rs @@ -916,18 +916,6 @@ impl Bot { // force's own forced event: a lance with nothing left to win is // preserving even if its formation has also come apart. let objective_lost = objective.is_some_and(|state| state.unavailable().is_some()); - self.focus = Self::company_focus(theirs); - if let Some(focus) = self.focus { - if let Some(enemy) = theirs.iter().find(|unit| unit.id == focus) { - eprintln!( - "[sds-bot] company focus: {} #{} ({} BV, {} health)", - enemy.name, - enemy.id, - enemy.bv, - enemy.armor + enemy.internal - ); - } - } let mut standings: Vec = Vec::new(); for force in forces { @@ -1279,6 +1267,22 @@ impl Bot { .filter(|u| !u.friendly && !u.destroyed) .cloned() .collect(); + // **The focus is chosen before the units measure.** The unit's menu + // carries `focus_damage` per candidate, so the id has to be settled + // before the proposals are built; waiting for the tactic pass would + // make every menu a round stale. + self.focus = Self::company_focus(&seen); + if let Some(focus) = self.focus { + if let Some(enemy) = seen.iter().find(|unit| unit.id == focus) { + eprintln!( + "[sds-bot] company focus: {} #{} ({} BV, {} health)", + enemy.name, + enemy.id, + enemy.bv, + enemy.armor + enemy.internal + ); + } + } if mine.is_empty() { self.planned = Some(Planned { round: observation.round, @@ -1336,6 +1340,7 @@ impl Bot { unit: unit.id, weights: surfacing.clone(), latches: self.latches.clone(), + focus: self.focus, }; let lance = Self::lance_of(unit); thinking.push(tokio::spawn(async move { @@ -1430,7 +1435,6 @@ impl Bot { proposal: proposed, objective: objective.clone(), relations: self.relations.clone(), - focus: self.focus, }) .await?; let mut decision = answer.into_decision()?; diff --git a/crates/sds-bot/src/unit.rs b/crates/sds-bot/src/unit.rs index e6c045b6..9bf517aa 100644 --- a/crates/sds-bot/src/unit.rs +++ b/crates/sds-bot/src/unit.rs @@ -417,10 +417,11 @@ impl Thinker for UnitThinker { unit, weights, latches, + focus, } => { let cache = self.caches.los(&board); let options = self.caches.options(&board); - Ok(Response::Thought(Box::new(propose( + Ok(Response::Thought(Box::new(propose_with_focus( &observation, &board, Some(&options), @@ -429,6 +430,7 @@ impl Thinker for UnitThinker { &weights, &latches, Some(self.caches.memos()), + focus, )))) } Request::Consider { force, .. } => { @@ -616,6 +618,7 @@ pub fn drop_banned_or_retry(thought: &mut UnitThought, memory: &mut RefusalMemor } #[allow(clippy::too_many_arguments)] +#[cfg(test)] pub fn propose( observation: &Observation, board: &Board, @@ -626,7 +629,33 @@ pub fn propose( latches: &Latches, memos: Option<&Memos>, ) -> UnitThought { - propose_including( + propose_with_focus( + observation, + board, + options, + los, + unit_id, + weights, + latches, + memos, + None, + ) +} + +/// The same menu, with the company's focus measured into every proposal. +#[allow(clippy::too_many_arguments)] +pub fn propose_with_focus( + observation: &Observation, + board: &Board, + options: Option<&OptionsCache>, + los: &LosCache, + unit_id: i32, + weights: &Weights, + latches: &Latches, + memos: Option<&Memos>, + focus: Option, +) -> UnitThought { + propose_including_with_focus( observation, board, options, @@ -636,6 +665,7 @@ pub fn propose( latches, None, memos, + focus, ) .0 } @@ -660,6 +690,33 @@ pub fn propose_including( latches: &Latches, observed: Option, memos: Option<&Memos>, +) -> (UnitThought, Option) { + propose_including_with_focus( + observation, + board, + options, + los, + unit_id, + weights, + latches, + observed, + memos, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn propose_including_with_focus( + observation: &Observation, + board: &Board, + options: Option<&OptionsCache>, + los: &LosCache, + unit_id: i32, + weights: &Weights, + latches: &Latches, + observed: Option, + memos: Option<&Memos>, + focus: Option, ) -> (UnitThought, Option) { // The side's memo when there is a side to share one with, and a local one // otherwise. `None` is the offline caller - imitation builds a cache per @@ -870,7 +927,9 @@ pub fn propose_including( // assumed. let (hits_before, misses_before) = los.counters(); let (option_hits_before, option_misses_before) = options.counters(); - if let Some(sweep) = candidates::generate(observation, options, los, &me, weights, memos) { + if let Some(sweep) = + candidates::generate_with_focus(observation, options, los, &me, weights, memos, focus) + { let (generated, cost) = (sweep.proposals, sweep.cost); let (hits, misses) = los.counters(); let (hits, misses) = (hits - hits_before, misses - misses_before); diff --git a/crates/sds-core/src/reconcile.rs b/crates/sds-core/src/reconcile.rs index d9df6a1e..33ad95e7 100644 --- a/crates/sds-core/src/reconcile.rs +++ b/crates/sds-core/src/reconcile.rs @@ -86,17 +86,6 @@ pub struct Doctrine { /// somebody: a target that stops shooting is worth more than the sum of /// the points it took. pub finish: f32, - /// The enemy the company has chosen to finish, if it has chosen one. - /// - /// A force sees only its own machines, so without this each lance picks - /// the best target in front of it and two lances can spend the turn on two - /// different machines. The coordinator sets it from the whole side's - /// picture; the bonus below is what makes a lance prefer the company's - /// target when its own menu is otherwise close. - pub focus: Option, - /// What a point of damage on the focus is worth beyond its ordinary - /// target term, as a share of the focus's remaining health. - pub focus_bonus: f32, } impl Default for Doctrine { @@ -104,8 +93,6 @@ impl Default for Doctrine { Self { effective: 1.0, finish: 0.5, - focus: None, - focus_bonus: 0.75, } } } @@ -389,15 +376,6 @@ pub fn worth( } worth.total += doctrine.term(*health, *damage); } - if let (Some(focus), Some(health)) = ( - doctrine.focus, - doctrine.focus.and_then(|id| remaining.get(&id).copied()), - ) { - if health > 0.0 { - let damage = dealt.get(&focus).copied().unwrap_or(0.0).clamp(0.0, health); - worth.total += doctrine.focus_bonus * damage / health; - } - } worth.total += worth.own; worth } @@ -739,21 +717,6 @@ fn marginal( let before = dealt.get(target).copied().unwrap_or(0.0); gain += doctrine.term(*health, before + damage) - doctrine.term(*health, before); } - if let (Some(focus), Some(health)) = ( - doctrine.focus, - doctrine.focus.and_then(|id| remaining.get(&id).copied()), - ) { - if health > 0.0 { - let before = dealt.get(&focus).copied().unwrap_or(0.0).clamp(0.0, health); - let damage = proposal - .damage_by_target - .get(&focus) - .copied() - .unwrap_or(0.0); - let after = (before + damage).clamp(0.0, health); - gain += doctrine.focus_bonus * (after - before) / health; - } - } gain } @@ -1003,39 +966,6 @@ mod tests { ); } - /// The company's focus pulls a unit off its own marginally better target. - #[test] - fn a_focus_target_changes_which_of_two_targets_a_force_takes() { - let health = BTreeMap::from([(11, 40.0), (12, 40.0)]); - let thoughts = vec![menu( - 1, - vec![ - offer(1, "into 11", hex(4, 4), 0.90, &[(11, 10.0)]), - offer(1, "into 12", hex(4, 6), 0.80, &[(12, 10.0)]), - ], - )]; - let weights = weights(); - let alone = reconcile( - &thoughts, - &health, - Scoring::uniform(&weights), - &Doctrine::default(), - ); - assert_eq!(alone.picks(), vec![(1, 0)], "the unit's own choice"); - - let doctrine = Doctrine { - focus: Some(12), - focus_bonus: 5.0, - ..Doctrine::default() - }; - let focused = reconcile(&thoughts, &health, Scoring::uniform(&weights), &doctrine); - assert_eq!( - focused.picks(), - vec![(1, 1)], - "the company focus did not move the unit" - ); - } - /// The rejected proposals are the record. Nothing here consumes a menu, and /// an order is an index back into one. #[test] diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index 6ad7d5cc..c296bef1 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -2434,6 +2434,23 @@ pub fn propose( scored: &[StandScore], foes: &[Foe<'_>], friends: &[&Unit], +) -> Vec { + propose_with_focus(los, us, sweep, scored, foes, friends, None) +} + +/// The same menu, with the company's focus measured into every proposal. +/// +/// A weight on the resulting `focus_damage` column is what makes a lance care +/// about the company's target; without one the column is measured and scores +/// nothing. +pub fn propose_with_focus( + los: &LosCache, + us: &Mover<'_>, + sweep: &Sweep, + scored: &[StandScore], + foes: &[Foe<'_>], + friends: &[&Unit], + focus: Option, ) -> Vec { let enemies: Vec<&Unit> = foes.iter().map(|foe| foe.who.unit).collect(); let diameter = board_diameter(los.board().width(), los.board().height()); @@ -2575,6 +2592,23 @@ pub fn propose( ); let lead = AmongCandidates::of(score.offence.expected_damage, &spread); features.record::(Score::min_max(lead.raw, lead.lowest, lead.highest)); + let focus_damage = focus + .and_then(|id| { + score + .per_target + .iter() + .find(|(target, _)| *target == id) + .map(|(_, target)| target.expected_damage) + }) + .unwrap_or(0.0); + let focus_health = focus + .and_then(|id| enemies.iter().find(|unit| unit.id == id)) + .map(|unit| (unit.armor + unit.internal).max(1) as f32) + .unwrap_or(1.0); + features.measure::(&crate::features::force::OnFocus { + damage: focus_damage, + health: focus_health, + }); proposals.push(Proposal { unit: us.who.unit.id, diff --git a/crates/sds-node/src/lib.rs b/crates/sds-node/src/lib.rs index 8355c6c8..b400d3bc 100644 --- a/crates/sds-node/src/lib.rs +++ b/crates/sds-node/src/lib.rs @@ -65,6 +65,13 @@ pub enum Request { /// unit thinker may be a process that has seen none of this match. #[serde(default)] latches: Latches, + /// The enemy the company is finishing this round, if any. + /// + /// The unit measures `focus_damage` against it on every proposal, so + /// the column is in the decision row a fit reads. `None` when no + /// enemy is visible. + #[serde(default, skip_serializing_if = "Option::is_none")] + focus: Option, }, /// Force level, at the top of a round: what would you be doing? /// @@ -168,15 +175,6 @@ pub enum Request { /// a bombard force appoints one of its own. #[serde(default)] relations: Vec, - /// The enemy the company has chosen to finish this round, if any. - /// - /// A force sees its own machines and the enemy, so without a company - /// focus each lance picks the best target in front of it and two - /// lances can spend the turn on two machines. The coordinator chooses - /// from the whole side's picture; the force's reconciliation adds a - /// bonus for damage on this id. `None` when no enemy is visible. - #[serde(default, skip_serializing_if = "Option::is_none")] - focus: Option, }, } @@ -336,7 +334,6 @@ mod tests { tactic_reason: Some(Because::Forced(Forcing::CohesionLost)), objective: None, relations: Vec::new(), - focus: None, }; let line = serde_json::to_string(&request).expect("a command serialises"); assert!(line.contains("\"tactic_reason\""), "the field has no name"); @@ -371,7 +368,6 @@ mod tests { tactic_reason: None, objective: None, relations: relations.clone(), - focus: None, }; let line = serde_json::to_string(&request).expect("a command serialises"); let read: Request = serde_json::from_str(&line).expect("and reads back"); @@ -384,31 +380,6 @@ mod tests { assert_eq!(seen, relations, "the obligation did not survive the wire"); } - /// The company focus reaches the force that has to act on it. - #[test] - fn a_command_carries_the_company_focus() { - let request = Request::Command { - force: "1st Lance".into(), - thoughts: Vec::new(), - round: 5, - remaining: Default::default(), - claimed: Default::default(), - ours: Vec::new(), - proposal: None, - tactic: Some("engage".into()), - tactic_reason: None, - objective: None, - relations: Vec::new(), - focus: Some(11), - }; - let line = serde_json::to_string(&request).expect("a command serialises"); - let read: Request = serde_json::from_str(&line).expect("and reads back"); - let Request::Command { focus, .. } = read else { - panic!("it came back as something else"); - }; - assert_eq!(focus, Some(11)); - } - /// A coordinator that has no opinion is still readable by both ends. #[test] fn a_command_without_a_reason_is_the_same_shape() { @@ -424,7 +395,6 @@ mod tests { tactic_reason: None, objective: None, relations: Vec::new(), - focus: None, }; let line = serde_json::to_string(&request).expect("a command serialises"); assert!( diff --git a/plan/hierarchy.md b/plan/hierarchy.md index cd69dce9..367a03ca 100644 --- a/plan/hierarchy.md +++ b/plan/hierarchy.md @@ -44,10 +44,11 @@ transports (`local:`, `proc:`, `tcp:`) so a level can move to another machine. side-wide declaration list; movement now gets the company focus below - [x] The coordinator picks a **company focus**. It chooses the visible enemy with the highest battle value per point of health left, ties on the lower - id, and carries that id on `Request::Command`. `reconcile::Doctrine` - gains a focus term, so a force prefers damage on the company's target - when its own menu is otherwise close. Tested at the coordinator, the - wire and the reconciliation. + id, and carries that id to every unit's proposal. Each movement proposal + measures `focus_damage`, the share of the focus's remaining health it + removes, as an ordinary learnable column. The hand-authored weight is + 0.75, and a doctrine lens can scale it: a duellist cares less, a drilled + company more. Tested at the coordinator, the feature and the unit menu. - [ ] Two-pass planning: cheap scoring first so the force can pick a tactic, expensive LOS-bearing refinement second and only where the tactic says it matters. Unblocks the commander and cuts total work -- knotmirror 0.0.1