From 9965bab361069af759d1901c153ff084a848baf7 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 20:37:30 -0400 Subject: [PATCH] feat(training): reconstruct the opposing seat's moves as training rows Two consecutive observations of one movement phase bracket an enemy move, so the bot can record what the other side chose without calling it. Our own candidate menu is generated from the unit's previous state, the observed hex is added to it before anything is measured, and the row is marked `observed` so a corpus is never silently part clone. No Princess code is linked or invoked; what is inherited is an opinion, and imitate.rs writes down which one. --- bridge/sds/SdsClient.java | 11 + crates/sds-bot/src/imitate.rs | 719 +++++++++++++++++++++++ crates/sds-bot/src/main.rs | 27 +- crates/sds-bot/src/unit.rs | 92 ++- crates/sds-core/src/features/decision.rs | 85 +++ 5 files changed, 919 insertions(+), 15 deletions(-) create mode 100644 crates/sds-bot/src/imitate.rs diff --git a/bridge/sds/SdsClient.java b/bridge/sds/SdsClient.java index e36399e..f365a6a 100644 --- a/bridge/sds/SdsClient.java +++ b/bridge/sds/SdsClient.java @@ -75,6 +75,8 @@ public final class SdsClient extends BotClient { private final List command; private final long timeoutMillis; private final Path logDir; + /** The match this seat is playing, or null when the run keeps no logs. */ + private final String matchTag; /** Where this seat's decisions are appended, or null when there is no log dir. */ private final Path decisionLog; @@ -117,6 +119,7 @@ public final class SdsClient extends BotClient { this.command = command; this.timeoutMillis = timeoutMillis; this.logDir = logDir; + this.matchTag = matchTag; // One file per seat per match. The tag is needed because a benchmark // puts every match of a run in one directory and the seat names repeat: // without it, twenty matches would interleave into one file and the @@ -594,6 +597,14 @@ public final class SdsClient extends BotClient { if (logDir != null) { pb.environment().put("SDS_LOG_DIR", logDir.toString()); } + // The tag as well as the directory. A bot that writes a corpus of + // its own needs to name the file after the match, or a benchmark's + // forty games interleave into one file and nothing can be joined + // back to the result that labels it. This host writes no such file; + // it only says which match this is. + if (matchTag != null) { + pb.environment().put("SDS_MATCH_TAG", matchTag); + } pb.environment().put("SDS_SEAT", getName()); process = pb.start(); toBot = new BufferedWriter(new OutputStreamWriter( diff --git a/crates/sds-bot/src/imitate.rs b/crates/sds-bot/src/imitate.rs new file mode 100644 index 0000000..91951b0 --- /dev/null +++ b/crates/sds-bot/src/imitate.rs @@ -0,0 +1,719 @@ +//! Learning to move by watching the other side move. +//! +//! # What this is +//! +//! The bot's own decision log carries one label per *match* - the final BV +//! differential, smeared over every decision in it. That is a few dozen +//! effective training signals for a few hundred matches. Imitation gives a +//! label per *decision*: the enemy moved, and where it moved to is the answer. +//! No credit assignment, no discounting, thousands of rows a night. +//! +//! # How the move is recovered +//! +//! Nothing is asked of the opponent and no opponent code is called. The +//! observation already carries every visible unit's position, facing and +//! `done` flag, so two consecutive observations of the same movement phase are +//! a before and an after: +//! +//! 1. keep the previous observation; +//! 2. an enemy unit that was `done: false` and is now `done: true` took its +//! movement turn in between, and its position and facing now are where it +//! chose to end up; +//! 3. generate *our* candidate menu for that unit from its state in the +//! **previous** observation - which is where its MP, heat and damage were +//! when the choice was made; +//! 4. put the observed hex in that menu as one more candidate; +//! 5. measure every candidate with the same features the bot measures its own +//! with, and record the observed one as `chosen`. +//! +//! The observed move will usually not be in our menu. That is expected: our +//! generator offers about five shapes and Princess enumerates every legal path. +//! The row is still a comparison between what they took and what we would have +//! offered, which is exactly what `difference_rows` in `sds/train.py` fits. +//! +//! # The observation is ours, not theirs +//! +//! Everything here reads the bot client's own observation. Under double-blind +//! MegaMek has already filtered it, so a unit we cannot see is a unit we cannot +//! imitate - which is correct, and is the only reading that keeps the bot from +//! being a cheat. +//! +//! # The cost +//! +//! `docs/PRINCESS.md` documents the fault this repository exists to avoid: +//! `BasicPathRanker.rankPath` takes the **maximum** damage a hex can deal and +//! the **sum** of the damage it can take, so Princess is structurally blind to +//! the value of a crossfire and systematically pessimistic about advancing. +//! **Cloning its moves clones that blindness.** Weights bootstrapped this way +//! are a starting position, not a destination; see `plan/training.md`. +//! +//! No Princess code is linked, imported or invoked. Every order the bot gives +//! still has exactly one author. What is inherited is an *opinion*, and this +//! module exists so that inheritance is written down rather than silent. + +use std::collections::BTreeMap; + +use sds_core::features::decision::Decision; +use sds_core::features::latch::Latches; +use sds_core::features::Weights; +use sds_core::hex::distance; +use sds_core::stance::Stance; +use sds_core::wire::{Board, Coord, Observation, Unit}; +use serde::Serialize; + +use crate::unit; + +/// The phase a move can be reconstructed from. Positions change in other phases +/// too - a skid, a fall, a charge - and none of those are a decision anybody +/// made about where to stand. +const MOVEMENT: &str = "MOVEMENT"; + +/// Why a visible enemy produced no row. +/// +/// Counted rather than logged per unit: a match has hundreds of these and the +/// question is always "which reason, and how often", never "which unit". A +/// reconstruction that quietly drops most of what it sees looks identical in a +/// corpus to one that had nothing to see. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct Dropped { + /// The unit is not in the previous observation: it deployed, or came into + /// view, between the two. There is no "before" to generate a menu from. + pub unseen_before: u32, + /// Destroyed in one of the two observations. A wreck did not choose a hex. + pub destroyed: u32, + /// The unit had not finished its movement turn, or had already finished it + /// before the previous observation. Either way this pair of observations + /// does not bracket the decision. + pub not_this_turn: u32, + /// It ended further away than its own movement points can explain. That is + /// a displacement, a skid or a teleport, not a path we could have offered, + /// and guessing at it would put a fictional candidate in the corpus. + pub unreachable: u32, + /// Our own candidate generator returned nothing for it. + pub no_menu: u32, +} + +impl Dropped { + pub fn total(&self) -> u32 { + self.unseen_before + self.destroyed + self.not_this_turn + self.unreachable + self.no_menu + } + + fn merge(&mut self, other: &Dropped) { + self.unseen_before += other.unseen_before; + self.destroyed += other.destroyed; + self.not_this_turn += other.not_this_turn; + self.unreachable += other.unreachable; + self.no_menu += other.no_menu; + } +} + +/// One reconstructed decision, in the shape `sds/train.py` reads. +/// +/// The `Decision` is flattened, so the file is the same flat one-document-per +/// -decision form the host writes for the bot's own rows - `candidates`, +/// `chosen`, `learnable`, `local`, `policy` - and `sds train` needs no new +/// parsing to fit it. +/// +/// `owner` rather than a seat name: the observation carries player ids and no +/// names, and the match result carries both. Python resolves it. +#[derive(Debug, Clone, Serialize)] +pub struct Row { + pub round: i32, + pub phase: &'static str, + /// The player whose move this was. Resolved to a seat name against the + /// match result's `players[].id`, which is what gives the row its label. + pub owner: i32, + /// The seat that watched it happen. Not the author of the move - kept so a + /// row can be traced back to the bot process that wrote it. + pub observer: String, + #[serde(flatten)] + pub decision: Decision, +} + +/// The previous observation, and what has been reconstructed from it. +#[derive(Debug, Default)] +pub struct Observer { + previous: Option, + /// Rows emitted, by round. A `BTreeMap` because the counters are printed + /// at the end of a match and must print in the same order every run. + emitted: BTreeMap, + dropped: Dropped, +} + +impl Observer { + pub fn new() -> Self { + Self::default() + } + + pub fn rows_emitted(&self) -> u32 { + self.emitted.values().sum() + } + + pub fn dropped(&self) -> &Dropped { + &self.dropped + } + + /// Take in an observation, and return whatever enemy moves it completed. + /// + /// Only ever compares two observations of the same movement phase of the + /// same round. Across a round boundary the units in between have shot at + /// each other, so the "before" state a menu would be generated from is not + /// the state the choice was made in; across a phase boundary the position + /// change is not a movement decision at all. + pub fn observe( + &mut self, + observation: &Observation, + board: &Board, + weights: &Weights, + ) -> Vec { + let rows = match self.previous.as_ref() { + Some(previous) => { + let (rows, dropped) = reconstruct(previous, observation, board, weights); + self.dropped.merge(&dropped); + rows + } + None => Vec::new(), + }; + for row in &rows { + *self.emitted.entry(row.round).or_default() += 1; + } + // Kept whatever the phase, so the next movement observation has the + // most recent picture of the board to diff against. `reconstruct` + // refuses the pairs that are not two movement observations of one + // round; this only decides what "previous" means. + self.previous = Some(observation.clone()); + rows + } +} + +/// Every enemy move the pair of observations brackets. +/// +/// Free of the `Observer` so it can be tested from two hand-written +/// observations with no bot, no host and no match. +pub fn reconstruct( + previous: &Observation, + current: &Observation, + board: &Board, + weights: &Weights, +) -> (Vec, Dropped) { + let mut dropped = Dropped::default(); + if previous.phase != MOVEMENT || current.phase != MOVEMENT || previous.round != current.round { + return (Vec::new(), dropped); + } + + let mut rows = Vec::new(); + // Units in the order the observation lists them, which the bridge builds + // from MegaMek's entity vector and is stable within a match. Nothing here + // reads a clock, a hash map or a completion order. + for now in current.units.iter().filter(|u| !u.friendly) { + let Some(before) = previous.units.iter().find(|u| u.id == now.id) else { + dropped.unseen_before += 1; + continue; + }; + if before.destroyed || now.destroyed { + dropped.destroyed += 1; + continue; + } + // The bracket: not finished then, finished now. This is what makes one + // move produce exactly one row however many observations arrive while + // it is still the enemy's turn to move. + if before.done || !now.done { + dropped.not_this_turn += 1; + continue; + } + let end = Coord::new(now.x, now.y); + if !within_reach(before, end) { + dropped.unreachable += 1; + continue; + } + let Some(decision) = menu_for(previous, board, before, end, weights) else { + dropped.no_menu += 1; + continue; + }; + rows.push(Row { + round: current.round, + phase: MOVEMENT, + owner: now.owner_id, + observer: String::new(), + decision, + }); + } + (rows, dropped) +} + +/// Whether a unit's own movement points can explain where it ended up. +/// +/// Straight-line hexes against the best of walk, run and jump, plus one hex of +/// slack for a path that spent a point turning. Deliberately generous: this is +/// rejecting displacement and teleportation, not auditing the movement rules, +/// and a rule this module got subtly wrong would silently throw away real +/// moves. +fn within_reach(before: &Unit, end: Coord) -> bool { + let travelled = distance(Coord::new(before.x, before.y), end); + let reach = before.run_mp.max(before.walk_mp).max(before.jump_mp).max(0) + 1; + travelled <= reach +} + +/// Our candidate menu for one enemy unit, with the hex it actually took in it. +/// +/// The menu is generated from the **previous** observation, mirrored so that +/// the subject's side reads as friendly. `unit::propose` decides what is an +/// enemy from the `friendly` flag, and imitation is asking it a question about +/// somebody else's seat - so the flag is recomputed by team rather than +/// reinterpreted. +fn menu_for( + previous: &Observation, + board: &Board, + subject: &Unit, + end: Coord, + weights: &Weights, +) -> Option { + let mirrored = mirror(previous, subject); + // No latches and no stance. Neither is knowable for another player's force, + // and inventing one would put a column in the corpus that describes our + // memory rather than their decision. The features they feed come out + // constant across the row and `sds train` drops them as dead columns, which + // is the honest outcome. + let (thought, observed) = unit::propose_including( + &mirrored, + board, + subject.id, + &Stance::default(), + &Latches::new(), + Some(end), + ); + let observed = observed?; + let menu: Vec<(String, sds_core::features::FeatureVector)> = thought + .proposals + .iter() + .map(|p| (p.label.clone(), p.features.clone())) + .collect(); + Decision::score(subject.id, &menu, weights)?.attributed_to_observation(observed) +} + +/// The same observation seen from another player's seat. +/// +/// Only the point of view changes. No unit is added, removed, moved or +/// undamaged: what a mirrored observation says about the board is exactly what +/// ours said, which is the property that keeps this from being a way to give +/// the bot a better view than it earned. +fn mirror(observation: &Observation, subject: &Unit) -> Observation { + let mut mirrored = observation.clone(); + mirrored.player_id = subject.owner_id; + mirrored.team = subject.team; + mirrored.actor = Some(subject.id); + // Ours, and about our units. Neither survives the change of seat, and + // leaving them in would offer another player's unit our shots. + mirrored.shots.clear(); + mirrored.deploy_hexes.clear(); + for unit in &mut mirrored.units { + // Same team, or the same player when the scenario declares no teams - + // the two cases the bridge already distinguishes when it sets this flag + // for us, in `Observation.unit`. + unit.friendly = + (unit.team == subject.team && subject.team != 0) || unit.owner_id == subject.owner_id; + } + mirrored +} + +/// The imitation corpus, as a file beside the decision log. +/// +/// A separate file rather than more lines in `-.decisions.jsonl`, +/// for two reasons. The decision log is written by the host from what comes +/// back on the wire, one line per reply, and a movement turn can complete +/// several enemy moves at once - there is no reply to hang them on. And a +/// corpus that is silently half self-play and half imitation is a corpus nobody +/// can interpret: `sds train` reads `*.decisions.jsonl` and has to be *asked* +/// for the imitation rows with `--imitation`. +/// +/// The rows are marked as well as separated. `policy` rides on every row, so +/// the provenance survives somebody concatenating the two files by hand. +pub struct Recorder { + observer: Observer, + path: std::path::PathBuf, + seat: String, + file: Option>, + broken: bool, +} + +/// A seat name as a file name, the same folding `SdsClient.safeName` does. +/// +/// It has to be the same or the tag/seat split in `sds/viewer.py` stops working +/// on one of the two files. +fn safe_name(name: &str) -> String { + let mut out = String::new(); + let mut last_underscore = false; + for c in name.chars() { + if c.is_ascii_alphanumeric() || c == '_' { + out.push(c); + last_underscore = c == '_'; + } else if !last_underscore { + out.push('_'); + last_underscore = true; + } + } + if out.is_empty() { + "seat".to_string() + } else { + out + } +} + +impl Recorder { + /// A recorder, or `None` when this run was not asked for one. + /// + /// Off unless `SDS_IMITATE` is set. Reconstructing a menu for every enemy + /// that moves is roughly the cost of a second bot thinking, and a benchmark + /// measuring play should not be paying it by accident. + /// + /// `SDS_LOG_DIR`, `SDS_SEAT` and `SDS_MATCH_TAG` all come from `SdsClient`. + /// Without them there is nowhere to put a row that could later be joined to + /// a match result, so it says so and stays off rather than writing a file + /// nothing can label. + pub fn from_env() -> Option { + if !matches!( + std::env::var("SDS_IMITATE").as_deref(), + Ok("1") | Ok("true") + ) { + return None; + } + let dir = std::env::var("SDS_LOG_DIR").ok(); + let tag = std::env::var("SDS_MATCH_TAG").ok(); + let seat = std::env::var("SDS_SEAT").unwrap_or_default(); + let (Some(dir), Some(tag)) = (dir, tag) else { + eprintln!( + "[sds-bot] SDS_IMITATE is set but SDS_LOG_DIR/SDS_MATCH_TAG are not; \ + no imitation corpus will be written" + ); + return None; + }; + let path = + std::path::Path::new(&dir).join(format!("{tag}-{}.imitation.jsonl", safe_name(&seat))); + eprintln!("[sds-bot] imitation corpus: {}", path.display()); + Some(Self { + observer: Observer::new(), + path, + seat, + file: None, + broken: false, + }) + } + + /// Feed one observation in and append whatever it completed. + /// + /// Never fatal. A corpus that cannot be written must not end a match that + /// is otherwise fine, so the first failure is reported and the rest are + /// silent - the same rule `SdsClient.record` follows for the decision log. + pub fn observe(&mut self, observation: &Observation, board: &Board, weights: &Weights) { + let mut rows = self.observer.observe(observation, board, weights); + if rows.is_empty() || self.broken { + return; + } + for row in &mut rows { + row.observer.clone_from(&self.seat); + } + if let Err(error) = self.append(&rows) { + self.broken = true; + eprintln!( + "[sds-bot] could not write {}; no further imitation rows: {error}", + self.path.display() + ); + } + } + + fn append(&mut self, rows: &[Row]) -> std::io::Result<()> { + use std::io::Write; + if self.file.is_none() { + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + self.file = Some(std::io::BufWriter::new(file)); + } + let file = self.file.as_mut().expect("just opened"); + for row in rows { + let line = serde_json::to_string(row) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + } + // Per batch, so the file is readable while the match is still running. + file.flush() + } + + /// What was reconstructed and what was not, for the end of a match. + pub fn summary(&self) -> String { + let dropped = self.observer.dropped(); + format!( + "[sds-bot] imitation: {} row(s); dropped {} (unseen {}, destroyed {}, \ + not this turn {}, unreachable {}, no menu {})", + self.observer.rows_emitted(), + dropped.total(), + dropped.unseen_before, + dropped.destroyed, + dropped.not_this_turn, + dropped.unreachable, + dropped.no_menu, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sds_core::wire::{BoardHex, Weapon}; + + fn board() -> Board { + let mut hexes = Vec::new(); + for x in 0..12 { + for y in 0..12 { + hexes.push(BoardHex { + x, + y, + level: 0, + terrain: BTreeMap::new(), + }); + } + } + Board { + width: 12, + height: 12, + hexes, + } + } + + fn unit(id: i32, owner: i32, team: i32, friendly: bool, x: i32, y: i32) -> Unit { + Unit { + id, + name: format!("Mek {id}"), + owner_id: owner, + team, + friendly, + x, + y, + facing: 0, + elevation: 0, + weight: 55.0, + walk_mp: 4, + run_mp: 6, + jump_mp: 0, + armor: 100, + armor_max: 100, + internal: 60, + internal_max: 60, + heat: 0, + heat_capacity: 10, + prone: false, + shut_down: false, + destroyed: false, + crippled: false, + done: false, + gunnery: 4, + piloting: 5, + role: "BRAWLER".into(), + force_path: vec!["Alpha".into()], + locations: Vec::new(), + weapons: vec![Weapon { + id: 1, + name: "PPC".into(), + heat: 10, + short: 6, + medium: 12, + long_range: 18, + avg_damage_short: 10.0, + avg_damage_medium: 10.0, + avg_damage_long: 10.0, + rack_size: 0, + damage_per_packet: 10.0, + usable: true, + ammo: None, + }], + } + } + + fn observation(round: i32, phase: &str, units: Vec) -> Observation { + Observation { + seq: 1, + round, + phase: phase.into(), + player_id: 0, + team: 1, + actor: None, + units, + shots: Vec::new(), + deploy_hexes: Vec::new(), + } + } + + /// One of ours at (2,2), one of theirs that walks from (8,8) to (6,6). + fn pair() -> (Observation, Observation) { + let mine = unit(1, 0, 1, true, 2, 2); + let mut theirs_before = unit(2, 1, 2, false, 8, 8); + theirs_before.done = false; + let mut theirs_after = theirs_before.clone(); + theirs_after.x = 6; + theirs_after.y = 6; + theirs_after.facing = 3; + theirs_after.done = true; + ( + observation(4, MOVEMENT, vec![mine.clone(), theirs_before]), + observation(4, MOVEMENT, vec![mine, theirs_after]), + ) + } + + #[test] + fn a_completed_enemy_move_becomes_a_row_with_that_hex_chosen() { + let (before, after) = pair(); + let (rows, dropped) = reconstruct(&before, &after, &board(), &Weights::hand_authored()); + assert_eq!(dropped.total(), 0, "{dropped:?}"); + assert_eq!(rows.len(), 1); + + let row = &rows[0]; + assert_eq!(row.round, 4); + assert_eq!(row.owner, 1); + assert_eq!(row.decision.unit, 2); + assert_eq!( + row.decision.taken().label, + unit::OBSERVED_LABEL, + "the chosen candidate must be the reconstructed move" + ); + assert_eq!( + row.decision.policy, + sds_core::features::decision::Policy::Observed + ); + assert!( + row.decision.candidates.len() > 1, + "a row with no alternatives teaches nothing" + ); + assert!( + !row.decision.learnable.is_empty(), + "an imitation row has to carry fittable columns" + ); + } + + /// Our own units are not imitated. The bot already logs its own choices, + /// and a corpus that counted them twice would weight them twice. + #[test] + fn friendly_units_are_not_reconstructed() { + let (before, mut after) = pair(); + for unit in &mut after.units { + unit.friendly = true; + unit.done = true; + } + let (rows, _) = reconstruct(&before, &after, &board(), &Weights::hand_authored()); + assert!(rows.is_empty()); + } + + #[test] + fn a_destroyed_unit_is_dropped_rather_than_guessed_at() { + let (before, mut after) = pair(); + after.units[1].destroyed = true; + let (rows, dropped) = reconstruct(&before, &after, &board(), &Weights::hand_authored()); + assert!(rows.is_empty()); + assert_eq!(dropped.destroyed, 1); + } + + #[test] + fn a_unit_that_was_not_there_before_is_dropped() { + let (mut before, after) = pair(); + before.units.retain(|u| u.id != 2); + let (rows, dropped) = reconstruct(&before, &after, &board(), &Weights::hand_authored()); + assert!(rows.is_empty()); + assert_eq!(dropped.unseen_before, 1); + } + + /// A jump from one side of the map to the other is a displacement, not a + /// path. Our generator could not have offered it and pretending otherwise + /// would put a fictional candidate in the corpus. + #[test] + fn a_teleport_is_dropped() { + let (before, mut after) = pair(); + after.units[1].x = 0; + after.units[1].y = 11; + let (rows, dropped) = reconstruct(&before, &after, &board(), &Weights::hand_authored()); + assert!(rows.is_empty()); + assert_eq!(dropped.unreachable, 1); + } + + /// The bracket. A unit still to move, and a unit that finished before the + /// previous observation, both produce nothing. + #[test] + fn only_the_turn_between_the_two_observations_counts() { + let (before, mut after) = pair(); + after.units[1].done = false; + let (rows, dropped) = reconstruct(&before, &after, &board(), &Weights::hand_authored()); + assert!(rows.is_empty()); + assert_eq!(dropped.not_this_turn, 1); + + let (mut before, after) = pair(); + before.units[1].done = true; + let (rows, dropped) = reconstruct(&before, &after, &board(), &Weights::hand_authored()); + assert!(rows.is_empty()); + assert_eq!(dropped.not_this_turn, 1); + } + + /// A move that ends where it started is still a decision: Princess chose to + /// hold. It has to be in the corpus or the fit only ever sees advances. + #[test] + fn standing_still_is_a_reconstructed_move() { + let (before, mut after) = pair(); + after.units[1].x = before.units[1].x; + after.units[1].y = before.units[1].y; + let (rows, dropped) = reconstruct(&before, &after, &board(), &Weights::hand_authored()); + assert_eq!(dropped.total(), 0, "{dropped:?}"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].decision.taken().label, unit::OBSERVED_LABEL); + } + + #[test] + fn a_pair_that_is_not_two_movement_observations_of_one_round_is_refused() { + let (before, mut after) = pair(); + after.phase = "FIRING".into(); + let (rows, dropped) = reconstruct(&before, &after, &board(), &Weights::hand_authored()); + assert!(rows.is_empty()); + assert_eq!(dropped.total(), 0, "a refused pair is not a dropped unit"); + + let (before, mut after) = pair(); + after.round = 5; + let (rows, _) = reconstruct(&before, &after, &board(), &Weights::hand_authored()); + assert!(rows.is_empty()); + } + + /// The mirror changes the point of view and nothing else. + #[test] + fn mirroring_re_sides_the_units_without_moving_them() { + let (before, _) = pair(); + let subject = before.units[1].clone(); + let mirrored = mirror(&before, &subject); + assert_eq!(mirrored.player_id, 1); + assert_eq!(mirrored.team, 2); + for (was, now) in before.units.iter().zip(mirrored.units.iter()) { + assert_eq!((was.x, was.y, was.facing), (now.x, now.y, now.facing)); + assert_eq!(was.armor, now.armor); + assert_eq!(now.friendly, now.team == subject.team); + assert_ne!(now.friendly, was.friendly, "both units changed sides"); + } + } + + /// The `Observer` only emits a move once, however many observations of the + /// phase arrive after it. + /// The same folding the Java side does, so one match's two files split back + /// into the same tag and seat. + #[test] + fn a_seat_name_becomes_the_same_file_name_java_would_pick() { + assert_eq!(safe_name("Free Worlds League"), "Free_Worlds_League"); + assert_eq!(safe_name("Draconis-Combine.2"), "Draconis_Combine_2"); + assert_eq!(safe_name("!!!"), "_"); + } + + #[test] + fn the_observer_emits_one_row_per_move() { + let (before, after) = pair(); + let mut observer = Observer::new(); + let weights = Weights::hand_authored(); + assert!(observer.observe(&before, &board(), &weights).is_empty()); + assert_eq!(observer.observe(&after, &board(), &weights).len(), 1); + assert!(observer.observe(&after, &board(), &weights).is_empty()); + assert_eq!(observer.rows_emitted(), 1); + } +} diff --git a/crates/sds-bot/src/main.rs b/crates/sds-bot/src/main.rs index e490ff0..6d02b21 100644 --- a/crates/sds-bot/src/main.rs +++ b/crates/sds-bot/src/main.rs @@ -34,8 +34,14 @@ use sds_node::{Node, Registry, Request}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; mod force; +mod imitate; mod unit; +/// Stands in for a board that never arrived. A bot with no board cannot +/// reconstruct anything, and this keeps that a zero-width map rather than a +/// branch nobody tests. +static EMPTY_BOARD: std::sync::LazyLock = std::sync::LazyLock::new(Board::default); + /// The most allocations one unit is offered in a firing decision. /// /// One less than the twenty every performance estimate in this repository @@ -559,7 +565,12 @@ async fn main() -> Result<()> { Arc::new(force::ForceThinker::new(Grit::default(), weights.clone())), ); - let mut bot = Bot::new(®istry, weights)?; + let mut bot = Bot::new(®istry, weights.clone())?; + // Watching the other side move, when a run asks for it. Off by default: + // rebuilding a menu for every enemy that moves is roughly a second bot's + // worth of thinking, and a benchmark measuring play should not pay it by + // accident. `imitate.rs` documents what is inherited by doing this. + let mut imitating = imitate::Recorder::from_env(); // 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 @@ -609,6 +620,17 @@ async fn main() -> Result<()> { } } + // Before the decision, so the previous observation is still the one + // this bot last reasoned from, and so a reconstruction failure cannot + // change the order that goes back. + if let Some(recorder) = imitating.as_mut() { + recorder.observe( + &observation, + bot.board.as_ref().unwrap_or(&EMPTY_BOARD), + &weights, + ); + } + let action = match observation.phase.as_str() { "DEPLOYMENT" => bot.deployment(&observation), "FIRING" => bot.firing(&observation), @@ -642,5 +664,8 @@ async fn main() -> Result<()> { stdout.write_all(b"\n").await?; stdout.flush().await?; } + if let Some(recorder) = imitating.as_ref() { + eprintln!("{}", recorder.summary()); + } Ok(()) } diff --git a/crates/sds-bot/src/unit.rs b/crates/sds-bot/src/unit.rs index 39b0544..5a380dd 100644 --- a/crates/sds-bot/src/unit.rs +++ b/crates/sds-bot/src/unit.rs @@ -286,6 +286,12 @@ struct Offer { notes: Vec, } +/// The label the imitation candidate carries in a decision log. +/// +/// Distinctive on purpose: a row is searchable for the one candidate its own +/// bot did not generate. +pub const OBSERVED_LABEL: &str = "observed move"; + pub fn propose( observation: &Observation, board: &Board, @@ -293,15 +299,38 @@ pub fn propose( stance: &Stance, latches: &Latches, ) -> UnitThought { + propose_including(observation, board, unit_id, stance, latches, None).0 +} + +/// The same menu, plus one hex somebody else actually moved to. +/// +/// Used only by imitation. The extra candidate has to be in the list *before* +/// anything is measured, because `damage_lead` is min-maxed across the +/// decision's own candidates - appending a measured proposal afterwards would +/// give it a spread nothing else in the row shared. +/// +/// Returns the index of that candidate, or `None` when there was no observed +/// hex to add. +pub fn propose_including( + observation: &Observation, + board: &Board, + unit_id: i32, + stance: &Stance, + latches: &Latches, + observed: Option, +) -> (UnitThought, Option) { let me = observation.units.iter().find(|u| u.id == unit_id).cloned(); let Some(me) = me else { - return UnitThought { - unit: unit_id, - role: "undetermined".into(), - appraisal: Appraisal::default(), - proposals: Vec::new(), - role_conflict: Some("unit is not in the observation".into()), - }; + return ( + UnitThought { + unit: unit_id, + role: "undetermined".into(), + appraisal: Appraisal::default(), + proposals: Vec::new(), + role_conflict: Some("unit is not in the observation".into()), + }, + None, + ); }; let role = Role::parse(&me.role); let here = Coord::new(me.x, me.y); @@ -316,6 +345,7 @@ pub fn propose( let mut offers: Vec = Vec::new(); let mut appraisal = Appraisal::default(); let mut role_conflict = None; + let mut observed_pushed = false; // Standing still is always on the menu. It is the honest option when // everything else is worse, and having it named means "the lance chose to @@ -430,7 +460,38 @@ pub fn propose( walk_toward(board, here, me.facing, target, budget, &occupied, me.prone); offer(&format!("take {} range", role.name()), steps, end); } + + // Last, so its index is the end of the list once the closure is done + // with `offers`. The steps are a placeholder: an imitation row is never + // played, and the real path is not in the observation - only where the + // unit ended up. + if let Some(end) = observed { + let steps = if end == here { + Vec::new() + } else { + vec!["FORWARDS".to_string()] + }; + offer(OBSERVED_LABEL, steps, end); + observed_pushed = true; + } + } + + // No enemy on the board means no `offer` closure ran, and the observed hex + // still has to be in the menu or the row has nothing marked as chosen. + if let Some(end) = observed { + if !observed_pushed { + offers.push(Offer { + label: OBSERVED_LABEL.into(), + action: sds_core::wire::Action::Move { steps: Vec::new() }, + end, + damage_dealt: 0.0, + damage_taken: 0.0, + notes: vec![], + }); + observed_pushed = true; + } } + let observed_index = observed_pushed.then(|| offers.len() - 1); // The role's preference, folded into the appraisal rather than applied as a // rule. It tilts the lance; it does not bind it. @@ -441,13 +502,16 @@ pub fn propose( } let _ = stance; - UnitThought { - unit: me.id, - role: role.name().to_string(), - appraisal, - proposals: measure(&me, &offers, observation, latches), - role_conflict, - } + ( + UnitThought { + unit: me.id, + role: role.name().to_string(), + appraisal, + proposals: measure(&me, &offers, observation, latches), + role_conflict, + }, + observed_index, + ) } /// Measure every offer, and turn the lot into proposals. diff --git a/crates/sds-core/src/features/decision.rs b/crates/sds-core/src/features/decision.rs index e9880cd..86dbbad 100644 --- a/crates/sds-core/src/features/decision.rs +++ b/crates/sds-core/src/features/decision.rs @@ -17,6 +17,27 @@ use serde::{Deserialize, Serialize}; use super::{FeatureVector, Weights}; +/// Who chose the candidate marked `chosen`. +/// +/// A corpus may hold rows from two different authors. `Argmax` is the bot's own +/// play: `chosen` is the argmax of `value`, and a fit against it is asking "what +/// weights explain what I did". `Observed` is imitation: `chosen` is a move some +/// other player made, reconstructed from the observation, and `value` is what +/// *our* weights thought of it - so `chosen` is deliberately not the argmax. +/// +/// Recorded rather than inferred. "The chosen candidate was not the highest +/// value" is a broken log in one case and the entire point in the other, and +/// nothing downstream can tell those apart without being told. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Policy { + /// The bot's own choice: the argmax of `value` under the weights it played. + #[default] + Argmax, + /// Another player's choice, reconstructed from what the observation showed. + Observed, +} + /// One thing the bot could have done, measured and scored. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Candidate { @@ -43,6 +64,10 @@ pub struct Decision { /// Feature names that are min-maxed across this decision only. A fit must /// drop these; keeping one would fit the board. pub local: Vec, + /// Who `chosen` belongs to. Defaults to the bot's own argmax, so a row + /// written before this field existed still reads as what it was. + #[serde(default)] + pub policy: Policy, } impl Decision { @@ -86,6 +111,7 @@ impl Decision { chosen, learnable: learnable.into_iter().collect(), local: local.into_iter().collect(), + policy: Policy::Argmax, }) } @@ -93,6 +119,22 @@ impl Decision { pub fn taken(&self) -> &Candidate { &self.candidates[self.chosen] } + + /// The same menu, but with somebody else's choice marked as taken. + /// + /// The scoring is untouched: `value` stays the number *our* weights give + /// each candidate, which is what makes an imitation row readable as "this + /// is what we would have done, and this is what they did". Returns `None` + /// if the index is not in the menu, because a row pointing outside its own + /// candidate list is worse than no row. + pub fn attributed_to_observation(mut self, chosen: usize) -> Option { + if chosen >= self.candidates.len() { + return None; + } + self.chosen = chosen; + self.policy = Policy::Observed; + Some(self) + } } #[cfg(test)] @@ -127,6 +169,49 @@ mod tests { assert!((row.candidates[0].value - 1.5).abs() < 1e-6); } + /// An imitation row keeps our numbers and somebody else's answer. + #[test] + fn an_observed_row_moves_chosen_without_moving_value() { + let weights = Weights::default().with::(1.0); + let menu = vec![ + ("ours".to_string(), vector(0.9, 0.0)), + ("theirs".to_string(), vector(0.1, 1.0)), + ]; + let row = Decision::score(1, &menu, &weights).expect("scored"); + assert_eq!(row.chosen, 0); + assert_eq!(row.policy, Policy::Argmax); + let values: Vec = row.candidates.iter().map(|c| c.value).collect(); + + let observed = row.attributed_to_observation(1).expect("in the menu"); + assert_eq!(observed.chosen, 1); + assert_eq!(observed.policy, Policy::Observed); + assert_eq!( + observed + .candidates + .iter() + .map(|c| c.value) + .collect::>(), + values + ); + } + + #[test] + fn an_observed_index_outside_the_menu_is_refused() { + let weights = Weights::default().with::(1.0); + let menu = vec![("only".to_string(), vector(0.4, 0.0))]; + let row = Decision::score(1, &menu, &weights).expect("scored"); + assert!(row.attributed_to_observation(3).is_none()); + } + + /// A row written before `policy` existed is the bot's own play. + #[test] + fn a_row_without_a_policy_reads_as_argmax() { + let json = r#"{"unit":1,"candidates":[],"chosen":0, + "learnable":[],"local":[]}"#; + let row: Decision = serde_json::from_str(json).expect("parses"); + assert_eq!(row.policy, Policy::Argmax); + } + #[test] fn a_tie_goes_to_the_earlier_candidate() { let weights = Weights::default().with::(1.0); -- 2.51.2