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);