From 13664f1aa1911816e3fb7e24b32a01c0420198ea Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 20:08:54 -0400 Subject: [PATCH] feat(training): sds-bot --weights, and a named baseline file The bot loads a fitted `M` from the `weights` object of the file `sds train` writes; without the flag it plays the hand-authored set as before. Every failure is fatal and named - no file, not JSON, a feature that does not exist, a `local` feature that may not carry a weight - and a file that leaves a baseline feature at zero says which. `weights/hand-authored.json` is the baseline as a file, checked against `Weights::hand_authored` by a test, and the flag rides along in the harness's `--bot` command string. --- README.md | 17 ++++ crates/sds-bot/src/force.rs | 6 +- crates/sds-bot/src/main.rs | 99 +++++++++++++++++++-- crates/sds-core/src/features/mod.rs | 129 ++++++++++++++++++++++++++++ weights/hand-authored.json | 21 +++++ 5 files changed, 264 insertions(+), 8 deletions(-) create mode 100644 weights/hand-authored.json diff --git a/README.md b/README.md index b7bc415..3508604 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,23 @@ carries. And it prints, loudly, any weight whose sign contradicts the feature's own one-sentence description: `overkill` coming out positive is a broken label, not a discovery. +Then play the fitted set. The bot takes `--weights`, and the harness passes a +bot command through whole, so it rides along in `--bot`: + + ./sds.sh bench --games 60 --against main \ + --bot "/work/target/release/sds-bot --weights /work/weights/fitted.json" + +Without the flag the bot plays `weights/hand-authored.json`, which is the +permanent baseline: a fitted set that only beats an arbitrary one has proven +nothing. That file and `Weights::hand_authored` are checked against each other +by a test, and `sds-bot --print-weights` writes it out again. + +A weights file that cannot be read is fatal and says why — a missing file, a +name no feature answers to, a `local` feature that may not carry a weight. It +also says which features the file leaves at zero that the baseline weights. +Silently ignoring one would make a training run look like it worked and change +nothing. + The label is the match's final BV differential, given to every decision in that match, so a good move in a lost match is labelled bad. It averages out over matches and does not over decisions. Whether a fitted `M` is actually better is diff --git a/crates/sds-bot/src/force.rs b/crates/sds-bot/src/force.rs index 4a57010..298b6ab 100644 --- a/crates/sds-bot/src/force.rs +++ b/crates/sds-bot/src/force.rs @@ -42,16 +42,16 @@ pub struct ForceThinker { impl Default for ForceThinker { fn default() -> Self { - Self::new(Grit::default()) + Self::new(Grit::default(), Weights::hand_authored()) } } impl ForceThinker { - pub fn new(grit: Grit) -> Self { + pub fn new(grit: Grit, weights: Weights) -> Self { Self { commitments: Mutex::new(HashMap::new()), grit, - weights: Weights::hand_authored(), + weights, } } } diff --git a/crates/sds-bot/src/main.rs b/crates/sds-bot/src/main.rs index c3624fe..57e83a6 100644 --- a/crates/sds-bot/src/main.rs +++ b/crates/sds-bot/src/main.rs @@ -18,7 +18,7 @@ use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; -use anyhow::Result; +use anyhow::{Context, Result}; use sds_core::ev::EvCache; use sds_core::features::decision::Decision; use sds_core::features::firing::Volley; @@ -68,8 +68,63 @@ struct Bot { weights: Weights, } +/// Where the weights come from, and what to say when they cannot be read. +/// +/// Loud on every failure, on purpose. A weights file the bot shrugged off would +/// make a training run look like it worked and change nothing, which is the +/// worst outcome available: the bench would be measuring the baseline under the +/// fitted set's name. +fn load_weights(path: &std::path::Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("cannot read weights file {}", path.display()))?; + let document: serde_json::Value = + serde_json::from_str(&text).with_context(|| format!("{} is not JSON", path.display()))?; + let weights = Weights::from_document(&document) + .map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?; + if weights.is_empty() { + anyhow::bail!("{}: the `weights` object is empty", path.display()); + } + + // A fitted set that names fewer features than the baseline is legal - a + // regression can put a weight at zero - but it is never what somebody + // intended silently, so it is said out loud with the names in it. + let baseline = Weights::hand_authored(); + let missing: Vec<&str> = baseline + .names() + .into_iter() + .filter(|name| !weights.names().contains(name)) + .collect(); + if !missing.is_empty() { + eprintln!( + "[sds-bot] {} does not weight {} feature(s) the baseline does; each is zero: {}", + path.display(), + missing.len(), + missing.join(", ") + ); + } + let extra: Vec<&str> = weights + .names() + .into_iter() + .filter(|name| !baseline.names().contains(name)) + .collect(); + if !extra.is_empty() { + eprintln!( + "[sds-bot] {} weights {} feature(s) the baseline does not: {}", + path.display(), + extra.len(), + extra.join(", ") + ); + } + eprintln!( + "[sds-bot] weights from {}: {} feature(s)", + path.display(), + weights.names().len() + ); + Ok(weights) +} + impl Bot { - fn new(registry: &Registry) -> Result { + fn new(registry: &Registry, weights: Weights) -> Result { // Addresses, not types. The defaults keep everything in this process; // an operator moves any level elsewhere with an environment variable. let unit = std::env::var("SDS_UNIT_NODE").unwrap_or_else(|_| "local:unit".into()); @@ -92,7 +147,7 @@ impl Bot { training: HashMap::new(), declared: Vec::new(), latches: Latches::new(), - weights: Weights::hand_authored(), + weights, }) } @@ -460,11 +515,45 @@ impl Bot { #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() -> Result<()> { + // Two flags and no argument-parsing crate. `--weights ` is the one + // thing an operator changes about a run, and `--print-weights` is how the + // baseline gets out of the binary and into a file somebody can diff. + let mut args = std::env::args().skip(1); + let mut weights_path: Option = None; + while let Some(arg) = args.next() { + match arg.as_str() { + "--weights" => { + let path = args + .next() + .ok_or_else(|| anyhow::anyhow!("--weights needs a file"))?; + weights_path = Some(path.into()); + } + "--print-weights" => { + println!( + "{}", + serde_json::to_string_pretty(&Weights::hand_authored().to_document())? + ); + return Ok(()); + } + other => anyhow::bail!("unknown argument {other}; try --weights "), + } + } + let weights = match weights_path { + Some(path) => load_weights(&path)?, + None => { + eprintln!("[sds-bot] no --weights given; playing the hand-authored baseline"); + Weights::hand_authored() + } + }; + let mut registry = Registry::new(); registry.register("unit", Arc::new(unit::UnitThinker)); - registry.register("force", Arc::new(force::ForceThinker::new(Grit::default()))); + registry.register( + "force", + Arc::new(force::ForceThinker::new(Grit::default(), weights.clone())), + ); - let mut bot = Bot::new(®istry)?; + let mut bot = Bot::new(®istry, weights)?; // Said once, not per decision: what the first observation actually carried. // A bot and a host built from different commits still talk, and the failure // mode is silent - the bot reasons from totals, or from a rack with no diff --git a/crates/sds-core/src/features/mod.rs b/crates/sds-core/src/features/mod.rs index 29f16b8..e79dbd2 100644 --- a/crates/sds-core/src/features/mod.rs +++ b/crates/sds-core/src/features/mod.rs @@ -32,6 +32,7 @@ pub mod score; pub mod target; use std::collections::BTreeMap; +use std::fmt; use serde::{Deserialize, Serialize}; @@ -341,6 +342,91 @@ impl Weights { } } +/// Why a weights file was refused. +/// +/// Every one of these is fatal at the call site on purpose. A weights file that +/// is silently ignored makes a training run look like it worked and change +/// nothing, which is worse than not having one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WeightsError { + /// The document is not an object. + NotAnObject, + /// The document has no `weights` key, or it is not an object. + NoWeightsKey, + /// A weight that is not a number. + NotANumber(String), + /// A name no build of this bot has ever measured. Almost always a typo or a + /// file from a different tree. + UnknownFeature(String), + /// A `local` feature: min-maxed across one decision's candidates, so a + /// weight fitted against it has learned one board. + NotLearnable(String), +} + +impl fmt::Display for WeightsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WeightsError::NotAnObject => write!(f, "not a JSON object"), + WeightsError::NoWeightsKey => { + write!(f, "no `weights` object; sds train writes one, and so does weights/hand-authored.json") + } + WeightsError::NotANumber(name) => write!(f, "weight for `{name}` is not a number"), + WeightsError::UnknownFeature(name) => write!( + f, + "`{name}` is not a feature this bot has; see sds_core::features::catalogue" + ), + WeightsError::NotLearnable(name) => write!( + f, + "`{name}` is normalised across one decision's candidates and may not carry a weight" + ), + } + } +} + +impl std::error::Error for WeightsError {} + +impl Weights { + /// Read a weights document strictly, refusing anything it cannot account + /// for. + /// + /// The opposite of the [`Deserialize`] impl below, and both are wanted. A + /// vector arriving from another node of a slightly different build should + /// drop the column it does not know and keep talking; a file somebody + /// pointed the bot at should be refused loudly, because nobody typed it by + /// accident. + pub fn from_document(document: &serde_json::Value) -> Result { + let object = document.as_object().ok_or(WeightsError::NotAnObject)?; + let map = object + .get("weights") + .and_then(|w| w.as_object()) + .ok_or(WeightsError::NoWeightsKey)?; + let mut weights = Weights::default(); + for (name, value) in map { + let weight = value + .as_f64() + .ok_or_else(|| WeightsError::NotANumber(name.clone()))? + as f32; + let entry = + entry_named(name).ok_or_else(|| WeightsError::UnknownFeature(name.clone()))?; + if !entry.norm.learnable() { + return Err(WeightsError::NotLearnable(name.clone())); + } + weights.by_name.insert(entry.name, weight); + } + Ok(weights) + } + + /// The features this set names, in a fixed order. + pub fn names(&self) -> Vec<&'static str> { + self.by_name.keys().copied().collect() + } + + /// A document in the shape [`Weights::from_document`] reads. + pub fn to_document(&self) -> serde_json::Value { + serde_json::json!({ "weights": self }) + } +} + impl<'de> Deserialize<'de> for Weights { /// Read a weight set back, by name. /// @@ -462,6 +548,49 @@ mod tests { } } + /// The checked-in baseline and the code cannot drift apart. + /// + /// `weights/hand-authored.json` is what a bench is pointed at and what a + /// fitted set is compared against, and a file that had quietly stopped + /// matching the bot's default would make that comparison meaningless. + /// `include_str!` is compile time, so this costs the wasm build nothing. + #[test] + fn the_checked_in_baseline_is_the_hand_authored_set() { + const FILE: &str = include_str!("../../../../weights/hand-authored.json"); + let document: serde_json::Value = serde_json::from_str(FILE).expect("valid JSON"); + let from_file = Weights::from_document(&document).expect("a readable weights file"); + assert_eq!(from_file, Weights::hand_authored()); + } + + #[test] + fn a_weights_file_is_refused_rather_than_half_read() { + let unknown = serde_json::json!({"weights": {"expected_damage": 1.0, "wishful": 2.0}}); + assert_eq!( + Weights::from_document(&unknown), + Err(WeightsError::UnknownFeature("wishful".into())) + ); + let local = serde_json::json!({"weights": {"damage_lead": 1.0}}); + assert_eq!( + Weights::from_document(&local), + Err(WeightsError::NotLearnable("damage_lead".into())) + ); + assert_eq!( + Weights::from_document(&serde_json::json!({"epoch": 3})), + Err(WeightsError::NoWeightsKey) + ); + assert_eq!( + Weights::from_document(&serde_json::json!([1, 2])), + Err(WeightsError::NotAnObject) + ); + } + + #[test] + fn a_weights_document_round_trips() { + let weights = Weights::hand_authored(); + let back = Weights::from_document(&weights.to_document()).expect("reads back"); + assert_eq!(back, weights); + } + #[test] fn a_vector_reports_the_local_features_it_carries() { // The runtime half of the learning guard. The compile-time half is the diff --git a/weights/hand-authored.json b/weights/hand-authored.json new file mode 100644 index 0000000..12409ca --- /dev/null +++ b/weights/hand-authored.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "note": "The baseline M, written by hand. This is what the bot plays with when no --weights is given, and the set a fitted one has to beat: see Weights::hand_authored in crates/sds-core/src/features/mod.rs for the sentence behind each number, and plan/training.md for why the baseline is permanent. Every feature is normalised to 0.0..=1.0, so a weight is the points of value that feature is worth at full strength.", + "weights": { + "ammo_spent": -0.5, + "arc_spread": 1.0, + "concentration": 3.0, + "expected_damage": 4.0, + "has_been_fired_upon": 0.5, + "heat_incurred": -2.0, + "honour_broken": 1.0, + "overkill": -3.0, + "p_kill": 8.0, + "p_psr_threshold": 1.5, + "target_breach": 2.5, + "target_health": -2.0, + "target_skill": 1.0, + "target_threat": 3.0, + "weapon_concentration": 1.5 + } +} -- 2.51.2