//! Holding one column's *effective* weight at a value, whatever the tactic. //! //! This is instrumentation and nothing else. It exists so that the behavioural //! half of a control - copy a weights file, zero one column, play several seeds //! a side, compare a rate - measures the column it names and not something //! adjacent to it. //! //! **Why a weights file cannot do this on its own.** A force scores with //! [`crate::tactic::Assignment::retune`] of the weight set the bot was given, //! and that composition has two arms: //! //! - Where `Engage` prices a feature at anything, the tactic's change is a //! **ratio**. A base of nought stays nought through it, because //! `0.0 * anything` is nought - so for these columns a weights file already //! gives a clean control, under every tactic, and this module is not needed. //! - Where `Engage` prices a feature at nought, the tactic's weight is //! **added**. Reaching an effective nought under tactic `T` then needs a base //! of `-T[c]`, and that same base is the literal effective weight during //! every round the force spends on `Engage` - which takes the additive arm //! too. The control is then negative on the column it meant to switch off, //! for part of the run, by construction. //! //! [`file_control`] answers which of the two a column is, so an operator can be //! told before the matches rather than after them. The list of columns in each //! arm is computed from the tactic catalogue every time it is asked; it is //! deliberately not written down anywhere, because a written list of exactly //! this kind is already stale in `plan/tactics.md`. //! //! **Where it applies.** An [`Ablation`] is the last step of forming any weight //! set that scores something, which means after `retune` rather than before it. //! That is what makes the requested value the effective one under every tactic, //! and it is why this cannot be expressed as a different input file. //! //! Nothing here runs unless an operator asked for it. `sds-bot` holds an //! `Option` that is `None` without `--ablate`, and applying `None` is //! not a code path that touches the weights at all. use crate::features::{catalogue, Weights, WeightsError}; use crate::tactic::{self, Engage, Tactic}; /// One column, held at one value. #[derive(Debug, Clone, PartialEq)] pub struct Ablation { column: String, to: f32, } impl Ablation { /// Build one, refusing a column this bot does not measure or may not fit. pub fn new(column: &str, to: f32) -> Result { // Validated by doing it: `set_named` is the check, so there is no // second rule here that could disagree with the one that runs later. Weights::default().set_named(column, to)?; Ok(Self { column: column.to_string(), to, }) } /// `` or `=`, the form a command line takes. /// /// A bare column means nought, because switching a column off is what a /// control is for and making the common case say `=0` invites the typo /// where somebody writes the column alone and means it. pub fn parse(raw: &str) -> Result { let (column, to) = match raw.split_once('=') { Some((name, value)) => { let parsed: f32 = value .trim() .parse() .map_err(|_| format!("--ablate {raw}: `{value}` is not a number"))?; (name.trim(), parsed) } None => (raw.trim(), 0.0), }; Self::new(column, to).map_err(|error| format!("--ablate {raw}: {error}")) } pub fn column(&self) -> &str { &self.column } pub fn to(&self) -> f32 { self.to } /// The weight set with this column held at its value. /// /// Idempotent, which is what lets it be applied at every point a weight set /// is formed without any of them having to know about the others. pub fn apply(&self, weights: &Weights) -> Weights { let mut held = weights.clone(); // The column was validated when the `Ablation` was built, so this // cannot fail; if it somehow did, a control that silently ablated // nothing is the worse outcome. held.set_named(&self.column, self.to) .expect("an ablation validates its column when it is built"); held } /// Apply to an optional ablation, which is the shape every call site has. pub fn hold(this: Option<&Ablation>, weights: Weights) -> Weights { match this { Some(ablation) => ablation.apply(&weights), None => weights, } } /// What a corpus records about the arm it was played under. pub fn to_document(&self) -> serde_json::Value { serde_json::json!({ "column": self.column, "to": self.to }) } } /// Whether editing a weights file gives a clean control for one column. #[derive(Debug, Clone, PartialEq)] pub enum FileControl { /// `Engage` prices the column, so `retune` scales it and a base of nought /// stays nought under every tactic. A weights file is enough. Ratio, /// `Engage` prices the column at nought and at least one tactic adds to it, /// so no single base is nought under all of them. Carries what each tactic /// adds, and the base a file would need to zero the column under each. Additive { introduced: Vec<(&'static str, f32)>, }, /// Not a column any tactic touches and not one `Engage` prices: `retune` /// leaves it at the base under every tactic, so a file is enough. Untouched, } impl FileControl { /// Whether a weights-file control on this column means what it claims. pub fn is_sound(&self) -> bool { !matches!(self, FileControl::Additive { .. }) } /// The base a weights file would need to zero this column under every /// tactic, if any base can. /// /// `Some(0.0)` on the two sound arms, which is the ordinary answer. On the /// additive arm there are two cases and the difference is worth the return /// type: a column one tactic introduces has a base that zeroes it *under /// that tactic* - contaminated elsewhere, but solvable - while a column /// several tactics introduce at different weights has **no base at all** /// that is nought under all of them. Four of the five additive columns are /// the second kind, so this is the common case rather than a corner, and an /// operator being told "solve for -3.0" would be told to do something /// impossible. pub fn zeroing_base(&self) -> Option { match self { FileControl::Ratio | FileControl::Untouched => Some(0.0), FileControl::Additive { introduced } => { let (_, first) = introduced.first()?; introduced .iter() .all(|(_, weight)| weight == first) .then(|| -first) } } } } /// Which arm of `retune` a column lands in, computed from the catalogue. /// /// Not a list. `plan/tactics.md` carried a rule of this shape in prose and went /// stale anyway; the same argument applies here, so the answer is recomputed /// from [`tactic::catalogue`] on every call. pub fn file_control(column: &str) -> FileControl { if Engage::weights().get(column) != 0.0 { return FileControl::Ratio; } let introduced: Vec<(&'static str, f32)> = tactic::catalogue() .into_iter() .filter(|entry| entry.name != Engage::NAME) .filter_map(|entry| { let weight = entry.weights.get(column); (weight != 0.0).then_some((entry.name, weight)) }) .collect(); if introduced.is_empty() { FileControl::Untouched } else { FileControl::Additive { introduced } } } /// Every column, with the arm it lands in. For `--print-controls`. pub fn survey() -> Vec<(&'static str, FileControl)> { catalogue() .into_iter() .filter(|entry| entry.norm.learnable()) .map(|entry| (entry.name, file_control(entry.name))) .collect() } #[cfg(test)] mod tests { use super::*; use crate::features::{firing, positional}; use crate::tactic::Assignment; #[test] fn a_bare_column_means_nought() { let one = Ablation::parse("cover_quality").expect("a real column"); assert_eq!(one.to(), 0.0); assert_eq!(one.column(), "cover_quality"); } #[test] fn a_column_this_bot_does_not_have_is_refused() { assert!(Ablation::parse("cover_qualtiy").is_err()); assert!(Ablation::parse("cover_quality=x").is_err()); } /// The property the whole module exists for: the value asked for is the /// effective one under *every* tactic, on both arms of `retune`. /// /// `edge_distance` is the additive arm - `Engage` does not price it and /// `Withdraw` prices it -8.0 - and it is the case a weights file cannot /// express. `cover_quality` is the ratio arm, and is here so that the /// ablation is shown not to break the arm that was already fine. #[test] fn an_ablation_holds_under_every_tactic() { let fitted = Weights::hand_authored() .with::(3.5) .with::(1.25); for column in ["edge_distance", "cover_quality", "expected_damage"] { for to in [0.0, 2.0] { let ablation = Ablation::new(column, to).expect("a real column"); for order in [ Assignment::Engage, Assignment::Advance(None), Assignment::Harass, Assignment::Flank(None), Assignment::Entrench, Assignment::Break, Assignment::Regroup, Assignment::Withdraw, ] { let played = ablation.apply(&order.retune(&fitted)); assert_eq!( played.get(column), to, "{column} under {} should be held at {to}", order.name() ); } } } } /// What a weights file does instead, which is the finding this replaces. /// /// Zeroing `edge_distance` under `Withdraw` from a file needs a base of /// +8.0, and that base is +8.0 under `Engage` rather than nought - so the /// control is scoring the column it meant to switch off, at the largest /// weight in `Withdraw`'s vector, for every round a force spends engaging. #[test] fn a_weights_file_cannot_zero_an_additive_column() { let base = Weights::hand_authored().with::(8.0); assert_eq!(Assignment::Withdraw.retune(&base).get("edge_distance"), 0.0); assert_eq!(Assignment::Engage.retune(&base).get("edge_distance"), 8.0); } /// And the other arm, which needs no instrument at all. #[test] fn a_weights_file_can_zero_a_ratio_column() { let base = Weights::hand_authored().with::(0.0); for order in [Assignment::Engage, Assignment::Entrench, Assignment::Harass] { assert_eq!(order.retune(&base).get("expected_damage"), 0.0); } } /// The classifier agrees with the two tests above, so an operator is told /// which case they are in before spending matches on it. #[test] fn the_classifier_names_the_arm() { assert_eq!(file_control("expected_damage"), FileControl::Ratio); assert!(file_control("expected_damage").is_sound()); let edge = file_control("edge_distance"); assert!(!edge.is_sound()); let FileControl::Additive { introduced } = edge else { panic!("edge_distance is introduced by a tactic, not priced by Engage"); }; assert!(introduced.contains(&("withdraw", -8.0))); } /// A column several tactics introduce at different weights cannot be zeroed /// by any weights file, not merely by an inconvenient one. /// /// `enemy_nearest` is introduced by six tactics at six weights, so there is /// no base that is nought under all of them; `edge_distance` is introduced /// by `Withdraw` alone, so +8.0 zeroes it there and contaminates every /// other round. Both need `--ablate`, and for different reasons. #[test] fn some_columns_no_base_can_zero() { assert_eq!(file_control("edge_distance").zeroing_base(), Some(8.0)); assert_eq!(file_control("enemy_nearest").zeroing_base(), None); assert_eq!(file_control("expected_damage").zeroing_base(), Some(0.0)); } /// Every learnable column is classified, and the two arms are both /// populated - so a survey that quietly became all-one-arm is a failure /// here rather than a surprise in a control. #[test] fn every_column_lands_in_an_arm() { let survey = survey(); assert!(!survey.is_empty()); assert!(survey.iter().any(|(_, arm)| *arm == FileControl::Ratio)); assert!(survey .iter() .any(|(_, arm)| matches!(arm, FileControl::Additive { .. }))); } /// Applying no ablation is not a transformation of the weights. #[test] fn no_ablation_is_the_identity() { let fitted = Weights::hand_authored().with::(3.5); assert_eq!(Ablation::hold(None, fitted.clone()), fitted); } }