Something went wrong. Try again.
bayes for days
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323//! 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<Ablation>` 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<Self, WeightsError> { // 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, }) }
/// `<column>` or `<column>=<value>`, 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<Self, String> { 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<f32> { 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::<positional::CoverQuality>(3.5) .with::<positional::EdgeDistance>(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::<positional::EdgeDistance>(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::<firing::ExpectedDamage>(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::<positional::CoverQuality>(3.5); assert_eq!(Ablation::hold(None, fitted.clone()), fitted); }}