diff --git a/crates/phase-ai/src/bin/ai_duel.rs b/crates/phase-ai/src/bin/ai_duel.rs index 60716b2c05..538bb7f444 100644 --- a/crates/phase-ai/src/bin/ai_duel.rs +++ b/crates/phase-ai/src/bin/ai_duel.rs @@ -52,6 +52,7 @@ fn main() { let mut output: Option = None; let mut suite_filter: Option = None; let mut attribution = AttributionMode::Disabled; + let mut harvest_output: Option = None; let mut commander_feed = "feeds/mtggoldfish-commander.json".to_string(); let mut args_iter = args.iter().skip(1).peekable(); @@ -81,6 +82,7 @@ fn main() { "--output" => output = args_iter.next().map(PathBuf::from), "--suite-filter" => suite_filter = args_iter.next().cloned(), "--show-attribution" => attribution = AttributionMode::Enabled, + "--harvest" => harvest_output = args_iter.next().map(PathBuf::from), "--feed" => { if let Some(feed) = args_iter.next() { commander_feed = feed.clone(); @@ -135,6 +137,7 @@ fn main() { options.output_path = output_path.clone(); options.filter = suite_filter; options.attribution = attribution; + options.harvest_output = harvest_output; match run_suite(&db, &options) { Ok(_) => { eprintln!("\nSuite report written to {}", output_path.display()); @@ -682,6 +685,8 @@ fn print_usage() { eprintln!(" --suite-filter STR Only run matchups whose id contains STR"); eprintln!(" --show-attribution Capture per-policy decision traces and include"); eprintln!(" them in the JSON + markdown output."); + eprintln!(" --harvest PATH Harvest per-turn eval features to JSONL at PATH"); + eprintln!(" (Texel retrain corpus; forces sequential run)."); eprintln!(); eprintln!("Commander suite mode:"); eprintln!(" --commander-suite Run 4-player Commander candidate-seat rotations"); diff --git a/crates/phase-ai/src/duel_suite/harvest.rs b/crates/phase-ai/src/duel_suite/harvest.rs new file mode 100644 index 0000000000..2b9fc96929 --- /dev/null +++ b/crates/phase-ai/src/duel_suite/harvest.rs @@ -0,0 +1,416 @@ +//! Self-play eval-feature harvesting — the observer half of the Texel retrain +//! pipeline (U4). +//! +//! [`GameHarvester`] rides the [`super::run::drive_game_observed`] observer seam, +//! snapshotting the last quiescent (empty-stack) position of each completed turn +//! from **p0's** perspective. [`FeatureRow::extract`] is the single +//! reconstruction authority: the exact unweighted feature vector the planner's +//! leaf eval (`evaluate_with_strategy`) applies its weights to, minus the two +//! serve-time carve-outs (`energy_offset`, `threat_adjustment`). Fitting weights +//! against these rows and their final-outcome labels (logistic regression) is the +//! Texel tuning method; see `scripts/train_eval_weights.py`. +//! +//! Serialization is JSONL. One [`HarvestSink`] exists per suite run: line 1 is a +//! file-scoped meta record; every subsequent line is one [`HarvestRecord`]. + +use std::fs::File; +use std::io::{self, BufWriter, Write}; +use std::path::Path; + +use engine::types::game_state::GameState; +use engine::types::player::PlayerId; +use serde::{Deserialize, Serialize}; + +use crate::eval::{evaluate_features, EvalWeights}; +use crate::session::AiSession; + +/// p0's perspective is fixed for the harvest — the win-label is p0's outcome and +/// every feature is a (p0 − opponent) differential. +const HARVEST_PERSPECTIVE: PlayerId = PlayerId(0); + +/// The 9 fitted eval features plus the `energy_offset` regression control, all +/// unweighted. Field names are exactly the [`EvalWeights`] field names (no proxy +/// map): the Python trainer maps each coefficient directly onto its weight. +/// +/// This is the serve reconstruction of `evaluate_with_strategy`'s linear +/// component: `weighted_total(w)` equals the planner's leaf eval minus +/// `energy_offset` (a fixed serve-time offset) and `threat_adjustment` (an +/// unfitted heuristic). The serve-reconstruction test in `planner::mod` pins that +/// identity so a future strategic term without a matching field fails loudly. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FeatureRow { + pub life: f64, + pub board_presence: f64, + pub board_power: f64, + pub board_toughness: f64, + pub hand_size: f64, + pub aggression: f64, + /// `evaluate_features` nc-diff plus `card_advantage::differential` — the full + /// value the `card_advantage` weight multiplies at serve time. + pub card_advantage: f64, + /// `zone_eval::zone_bonus` for p0's archetype. + pub zone_quality: f64, + /// `SynergyGraph::board_synergy_bonus`. + pub synergy: f64, + /// Fixed-coefficient control (`energy × 0.1`); excluded from `weighted_total`, + /// discarded by the trainer. + pub energy_offset: f64, +} + +impl FeatureRow { + /// Reconstruct the fitted feature vector from `player`'s perspective. Returns + /// `None` for terminal positions (`evaluate_features` short-circuits with + /// `Err`) so terminal, label-leaking snapshots are never harvested. + /// + /// The `zone_quality` archetype and `synergy` graph are read from `session` + /// exactly as `build_ai_context_with_session` reads them for the live planner + /// (`session.deck_profile[player].archetype`, `session.synergy[player]`), so + /// the harvested vector matches the served one for the same session. + pub(crate) fn extract( + state: &GameState, + session: &AiSession, + player: PlayerId, + ) -> Option { + let features = evaluate_features(state, player).ok()?; + let differential = crate::card_advantage::differential(state, player); + let synergy = session + .synergy + .get(&player) + .map_or(0.0, |graph| graph.board_synergy_bonus(state, player)); + let archetype = session + .deck_profile + .get(&player) + .map(|profile| profile.archetype) + .unwrap_or_default(); + let zone_quality = crate::zone_eval::zone_bonus(state, player, archetype); + + Some(FeatureRow { + life: features.life, + board_presence: features.board_presence, + board_power: features.board_power, + board_toughness: features.board_toughness, + hand_size: features.hand_size, + aggression: features.aggression, + card_advantage: features.card_advantage_breakdown + differential, + zone_quality, + synergy, + energy_offset: features.energy_offset, + }) + } + + /// Weighted sum of all 9 fitted features, **excluding** `energy_offset`. Mirrors + /// `evaluate_with_strategy` minus its two serve-time carve-outs. + pub fn weighted_total(&self, w: &EvalWeights) -> f64 { + self.life * w.life + + self.board_presence * w.board_presence + + self.board_power * w.board_power + + self.board_toughness * w.board_toughness + + self.hand_size * w.hand_size + + self.aggression * w.aggression + + self.card_advantage * w.card_advantage + + self.zone_quality * w.zone_quality + + self.synergy * w.synergy + } +} + +/// One labeled training row: a quiescent p0-perspective feature vector plus the +/// per-game identity and the final win label. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct HarvestRecord { + pub seed: u64, + pub matchup_id: String, + pub game_idx: usize, + pub turn: u32, + pub features: FeatureRow, + pub won: bool, +} + +/// File-scoped provenance written as line 1 of each JSONL shard. Per-game +/// identity (`seed`, `matchup_id`, `game_idx`) lives on each record, since one +/// seed is ill-defined at file scope for a multi-matchup shard. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct HarvestMeta { + pub schema: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub card_data_hash: Option, + pub difficulty: String, +} + +/// Wrapper so the meta line serializes as `{"meta": {...}}` — records have no +/// `meta` key, so the trainer discriminates them by key presence. +#[derive(Serialize)] +struct MetaLine<'a> { + meta: &'a HarvestMeta, +} + +/// Per-game observer. Keeps the LAST empty-stack snapshot seen for the current +/// turn in `pending`; when `turn_number` advances, that snapshot is flushed to +/// `buffer` as the completed turn's quiescent record. A turn whose every +/// batch-boundary observation had a non-empty stack produces zero records (a gap +/// — sampling, not census). +pub(crate) struct GameHarvester { + seed: u64, + matchup_id: String, + game_idx: usize, + player: PlayerId, + /// `(turn_number, row)` — the latest empty-stack snapshot of the current turn. + pending: Option<(u32, FeatureRow)>, + /// Flushed quiescent snapshots of completed turns, in turn order. + buffer: Vec<(u32, FeatureRow)>, +} + +impl GameHarvester { + /// New harvester for one game. Perspective is fixed to p0. + pub fn new(seed: u64, matchup_id: String, game_idx: usize) -> Self { + Self { + seed, + matchup_id, + game_idx, + player: HARVEST_PERSPECTIVE, + pending: None, + buffer: Vec::new(), + } + } + + /// Observe a batch-boundary position. Flushes the previous turn's pending + /// snapshot on turn advance, then (re)sets `pending` for the current turn when + /// the stack is empty and a non-terminal feature vector is extractable. + pub fn observe(&mut self, state: &GameState, session: &AiSession) { + if let Some((pending_turn, _)) = &self.pending { + if state.turn_number > *pending_turn { + // Safe: guarded by the `is_some` match above. + let completed = self.pending.take().expect("pending present"); + self.buffer.push(completed); + } + } + + if state.stack.is_empty() { + if let Some(row) = FeatureRow::extract(state, session, self.player) { + self.pending = Some((state.turn_number, row)); + } + } + } + + /// Finish the game, dropping the in-progress final turn's pending snapshot + /// (terminal-adjacent positions are the most label-leaking). Returns empty for + /// `winner == None` (draws, action-cap games, panicked games) — an unlabeled + /// corpus is useless, so those games contribute nothing. + pub fn finish(self, winner: Option) -> Vec { + // `pending` is intentionally dropped with `self`. + let Some(winner) = winner else { + return Vec::new(); + }; + let won = winner == self.player; + self.buffer + .into_iter() + .map(|(turn, features)| HarvestRecord { + seed: self.seed, + matchup_id: self.matchup_id.clone(), + game_idx: self.game_idx, + turn, + features, + won, + }) + .collect() + } +} + +/// One sink per suite run: created once before the matchup loop, writes the +/// single file-scoped meta line at construction, then appends every game's +/// records through the same `BufWriter`. Per-matchup `File::create` is wrong — it +/// would clobber the file and emit N meta lines under the sequential branch. +pub(crate) struct HarvestSink { + writer: BufWriter, +} + +impl HarvestSink { + /// Create the shard, writing the meta record as line 1. + pub fn create(path: &Path, meta: &HarvestMeta) -> io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut writer = BufWriter::new(File::create(path)?); + serde_json::to_writer(&mut writer, &MetaLine { meta }).map_err(io::Error::other)?; + writer.write_all(b"\n")?; + Ok(Self { writer }) + } + + /// Append a game's records, one JSON object per line. + pub fn write_records(&mut self, records: &[HarvestRecord]) -> io::Result<()> { + for record in records { + serde_json::to_writer(&mut self.writer, record).map_err(io::Error::other)?; + self.writer.write_all(b"\n")?; + } + Ok(()) + } + + /// Flush the buffered writer to disk. + pub fn flush(&mut self) -> io::Result<()> { + self.writer.flush() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use engine::types::game_state::{StackEntry, StackEntryKind, WaitingFor}; + use engine::types::identifiers::{CardId, ObjectId}; + + fn fresh_session() -> AiSession { + AiSession::empty() + } + + /// A minimal non-empty stack: contents are irrelevant to `observe`, which only + /// reads `stack.is_empty()`. + fn push_stack(state: &mut GameState) { + state.stack.push_back(StackEntry { + id: ObjectId(9_999), + source_id: ObjectId(9_999), + controller: PlayerId(0), + kind: StackEntryKind::Spell { + card_id: CardId(9_999), + ability: None, + casting_variant: Default::default(), + actual_mana_spent: 0, + }, + }); + } + + /// Row "Snapshots are quiescent" + "Turn-flush semantics": a non-empty-stack + /// batch boundary yields no snapshot; each buffered record is a distinct, + /// strictly-increasing completed turn; the in-progress final turn is dropped. + #[test] + fn observe_gates_on_empty_stack_and_flushes_per_completed_turn() { + let session = fresh_session(); + let mut harvester = GameHarvester::new(42, "unit".to_string(), 0); + + let mut state = GameState::new_two_player(42); + + // Turn 1: a NON-empty-stack boundary (reach-guard — the gate must filter + // this) then an empty-stack boundary that sets pending. + state.turn_number = 1; + push_stack(&mut state); + harvester.observe(&state, &session); + assert!( + harvester.pending.is_none(), + "non-empty stack must not snapshot" + ); + state.stack.clear(); + harvester.observe(&state, &session); + assert_eq!( + harvester.pending.as_ref().map(|(t, _)| *t), + Some(1), + "empty stack sets pending for the current turn" + ); + + // Turn 2: empty-stack boundary flushes turn 1 and sets pending for 2. A + // later non-empty boundary in the SAME turn neither flushes nor overwrites. + state.turn_number = 2; + harvester.observe(&state, &session); + assert_eq!(harvester.buffer.len(), 1, "turn advance flushes turn 1"); + push_stack(&mut state); + harvester.observe(&state, &session); + assert_eq!( + harvester.pending.as_ref().map(|(t, _)| *t), + Some(2), + "non-empty mid-turn boundary keeps turn 2's pending" + ); + state.stack.clear(); + + // Turn 3: flush turn 2. Leaves turn 3 pending (in-progress final turn). + state.turn_number = 3; + harvester.observe(&state, &session); + assert_eq!(harvester.buffer.len(), 2); + + let records = harvester.finish(Some(PlayerId(0))); + let turns: Vec = records.iter().map(|r| r.turn).collect(); + assert_eq!( + turns, + vec![1, 2], + "≤1 record per completed turn, strictly increasing, final turn dropped" + ); + assert!(records.iter().all(|r| r.won), "p0 won ⇒ every row won:true"); + } + + /// Row "Labeling correct": winner drives `won`; a `None` winner yields no rows + /// even though completed turns were buffered (reach-guard). + #[test] + fn finish_labels_by_winner_and_drops_unlabeled_games() { + let session = fresh_session(); + let build = || { + let mut h = GameHarvester::new(1, "m".to_string(), 3); + let mut state = GameState::new_two_player(1); + state.turn_number = 1; + h.observe(&state, &session); // empty stack ⇒ pending turn 1 + state.turn_number = 2; + h.observe(&state, &session); // flush turn 1 + h + }; + + assert!(build().finish(Some(PlayerId(0))).iter().all(|r| r.won)); + assert!(build().finish(Some(PlayerId(1))).iter().all(|r| !r.won)); + assert_eq!( + build().finish(Some(PlayerId(1)))[0].game_idx, + 3, + "per-game identity is carried onto the record" + ); + assert!( + build().finish(None).is_empty(), + "unlabeled game (draw / cap / panic) contributes no rows" + ); + } + + /// Terminal positions are skipped at capture time. + #[test] + fn observe_skips_terminal_positions() { + let session = fresh_session(); + let mut harvester = GameHarvester::new(1, "m".to_string(), 0); + let mut state = GameState::new_two_player(1); + state.turn_number = 1; + state.waiting_for = WaitingFor::GameOver { + winner: Some(PlayerId(0)), + }; + harvester.observe(&state, &session); + assert!( + harvester.pending.is_none(), + "GameOver ⇒ evaluate_features Err ⇒ no snapshot" + ); + } + + /// `FeatureRow` survives a JSONL round-trip and `weighted_total` sums the 9 + /// fitted features while excluding the energy control. + #[test] + fn feature_row_round_trips_and_excludes_energy_from_weighted_total() { + let row = FeatureRow { + life: 1.0, + board_presence: 2.0, + board_power: 3.0, + board_toughness: 4.0, + hand_size: 5.0, + aggression: 6.0, + card_advantage: 7.0, + zone_quality: 8.0, + synergy: 9.0, + energy_offset: 100.0, + }; + let json = serde_json::to_string(&row).unwrap(); + let back: FeatureRow = serde_json::from_str(&json).unwrap(); + assert_eq!(row, back); + + let w = EvalWeights { + life: 1.0, + aggression: 1.0, + board_presence: 1.0, + board_power: 1.0, + board_toughness: 1.0, + hand_size: 1.0, + zone_quality: 1.0, + card_advantage: 1.0, + synergy: 1.0, + }; + // 1+2+3+4+5+6+7+8+9 = 45; energy_offset (100) is excluded. + assert!((row.weighted_total(&w) - 45.0).abs() < 1e-9); + } +} diff --git a/crates/phase-ai/src/duel_suite/mod.rs b/crates/phase-ai/src/duel_suite/mod.rs index c6c72ed2f3..3ffed86daa 100644 --- a/crates/phase-ai/src/duel_suite/mod.rs +++ b/crates/phase-ai/src/duel_suite/mod.rs @@ -8,6 +8,7 @@ pub mod attribution; pub mod compare; +pub mod harvest; pub mod inline_decks; pub mod perf; pub mod run; diff --git a/crates/phase-ai/src/duel_suite/run.rs b/crates/phase-ai/src/duel_suite/run.rs index b380ccb8b0..9a492a81d1 100644 --- a/crates/phase-ai/src/duel_suite/run.rs +++ b/crates/phase-ai/src/duel_suite/run.rs @@ -26,6 +26,7 @@ use crate::auto_play::run_ai_actions; use crate::config::{create_config_for_players, AiConfig, AiDifficulty, Platform}; use super::attribution::{aggregate_events, CaptureLayer, MatchupAttribution}; +use super::harvest::{self, HarvestSink}; use super::{all_matchups, resolve_deck_ref, Expected, FeatureKind, MatchupSpec}; /// Safety cap on total AI actions per game — matches the constant in @@ -169,6 +170,10 @@ pub struct SuiteOptions { pub attribution: AttributionMode, pub git_sha: Option, pub card_data_hash: Option, + /// When set, harvest per-turn eval features to this JSONL path (Texel retrain + /// corpus). Like attribution, harvesting forces the sequential branch so a + /// single `HarvestSink` owns the file. + pub harvest_output: Option, } impl SuiteOptions { @@ -182,6 +187,7 @@ impl SuiteOptions { attribution: AttributionMode::Disabled, git_sha: None, card_data_hash: None, + harvest_output: None, } } } @@ -194,24 +200,45 @@ pub fn run_suite(db: &CardDatabase, options: &SuiteOptions) -> Result None, }; + // One sink per suite run: created ONCE here, before the matchup loop, writing + // the single file-scoped meta line at construction. `run_all_matchups` (which + // harvesting forces onto the sequential branch) appends every game's records + // through this same handle. + let mut harvest_sink = match &options.harvest_output { + Some(path) => { + let meta = harvest::HarvestMeta { + schema: 1, + git_sha: options.git_sha.clone(), + card_data_hash: options.card_data_hash.clone(), + difficulty: format!("{:?}", options.difficulty), + }; + Some(harvest::HarvestSink::create(path, &meta)?) + } + None => None, + }; + // Install the subscriber for the duration of this call. When attribution // is disabled, skip subscriber installation entirely — the // `event_enabled!` gate inside `emit_decision_trace` short-circuits and // `PolicyRegistry::verdicts()` is never invoked. - if let Some(layer) = capture.as_ref() { + let results = if let Some(layer) = capture.as_ref() { let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { tracing_subscriber::EnvFilter::new("phase_ai::decision_trace=debug") }); let subscriber = tracing_subscriber::registry::Registry::default() .with(filter) .with(layer.clone()); - let results = tracing::subscriber::with_default(subscriber, || { - run_all_matchups(db, options, capture.as_ref()) - }); - return finalize_report(options, results); + tracing::subscriber::with_default(subscriber, || { + run_all_matchups(db, options, capture.as_ref(), harvest_sink.as_mut()) + }) + } else { + run_all_matchups(db, options, None, harvest_sink.as_mut()) + }; + + if let Some(sink) = harvest_sink.as_mut() { + sink.flush()?; } - let results = run_all_matchups(db, options, None); finalize_report(options, results) } @@ -233,6 +260,7 @@ fn run_all_matchups( db: &CardDatabase, options: &SuiteOptions, capture: Option<&CaptureLayer>, + mut harvest_sink: Option<&mut HarvestSink>, ) -> Vec { let matchups = all_matchups(); let total = matchups.len(); @@ -245,10 +273,12 @@ fn run_all_matchups( .filter(|(_, spec)| matchup_selected(spec.id, options.filter.as_deref())) .collect(); - // Attribution drains a process-global tracing subscriber between matchups, - // so capture runs must stay sequential — concurrent matchups would interleave - // their decision-trace events into the one capture layer. - if capture.is_some() { + // Attribution drains a process-global tracing subscriber between matchups, so + // capture runs must stay sequential — concurrent matchups would interleave + // their decision-trace events into the one capture layer. Harvesting joins the + // same sequential branch: a single `HarvestSink` owns the output file and + // parallel writers would interleave records / corrupt the append stream. + if capture.is_some() || harvest_sink.is_some() { let mut results = Vec::with_capacity(selected.len()); for (idx, spec) in &selected { eprintln!( @@ -262,7 +292,8 @@ fn run_all_matchups( let _ = layer.drain(); } let matchup_seed = options.base_seed.wrapping_add(*idx as u64 * 1_000); - let mut result = run_single_matchup(db, spec, options, matchup_seed); + let mut result = + run_single_matchup(db, spec, options, matchup_seed, harvest_sink.as_deref_mut()); if let Some(layer) = capture { let events = layer.drain(); result.attribution = Some(aggregate_events(&events)); @@ -317,7 +348,9 @@ fn run_matchups_parallel( } let (idx, spec) = selected[pos]; let matchup_seed = options.base_seed.wrapping_add(idx as u64 * 1_000); - let result = run_single_matchup(db, spec, options, matchup_seed); + // Parallel path never harvests (harvesting forces the + // sequential branch in `run_all_matchups`). + let result = run_single_matchup(db, spec, options, matchup_seed, None); let completed = done.fetch_add(1, Ordering::Relaxed) + 1; eprintln!( "[{completed:>2}/{run_total}] {id} done (games: {games})", @@ -373,6 +406,7 @@ fn run_single_matchup( spec: &MatchupSpec, options: &SuiteOptions, matchup_seed: u64, + mut harvest_sink: Option<&mut HarvestSink>, ) -> MatchupResult { let payload = match build_payload(db, spec) { Ok(p) => p, @@ -389,13 +423,40 @@ fn run_single_matchup( for game_idx in 0..options.games_per_matchup { let seed = matchup_seed.wrapping_add(game_idx as u64); let start = Instant::now(); - let (winner, turns) = match std::panic::catch_unwind(AssertUnwindSafe(|| { - run_game(&payload, seed, options.difficulty) - })) { - Ok(result) => result, - Err(_) => { - eprintln!(" seed {seed} aborted: AI panic during suite game"); - (None, 0) + let (winner, turns) = if harvest_sink.is_some() { + // Harvester declared OUTSIDE catch_unwind. The observe closure's `&mut` + // borrow ends when the closure returns; `finish(winner)` then runs + // unconditionally (panic → `catch_unwind` Err → winner None → empty + // records, partial buffer dropped with the harvester). + let mut harvester = harvest::GameHarvester::new(seed, spec.id.to_string(), game_idx); + let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| { + run_game_observed(&payload, seed, options.difficulty, &mut |state, session| { + harvester.observe(state, session) + }) + })); + let (winner, turns) = match outcome { + Ok(result) => result, + Err(_) => { + eprintln!(" seed {seed} aborted: AI panic during suite game"); + (None, 0) + } + }; + let records = harvester.finish(winner); + if let Some(sink) = harvest_sink.as_deref_mut() { + if let Err(e) = sink.write_records(&records) { + eprintln!(" seed {seed}: harvest write failed: {e}"); + } + } + (winner, turns) + } else { + match std::panic::catch_unwind(AssertUnwindSafe(|| { + run_game(&payload, seed, options.difficulty) + })) { + Ok(result) => result, + Err(_) => { + eprintln!(" seed {seed} aborted: AI panic during suite game"); + (None, 0) + } } }; total_duration_ms += start.elapsed().as_millis(); @@ -541,6 +602,19 @@ fn run_game(payload: &DeckPayload, seed: u64, difficulty: AiDifficulty) -> (Opti drive_game(payload, seed, difficulty, MAX_TOTAL_ACTIONS) } +/// [`run_game`]'s observing sibling — same `MAX_TOTAL_ACTIONS` cap, so harvested +/// and unharvested suite games are byte-identical in `(winner, turns)`. Keeping +/// the action cap in lockstep with `run_game` is what guarantees no drift between +/// the two paths. +fn run_game_observed( + payload: &DeckPayload, + seed: u64, + difficulty: AiDifficulty, + observe: &mut dyn FnMut(&GameState, &std::sync::Arc), +) -> (Option, u32) { + drive_game_observed(payload, seed, difficulty, MAX_TOTAL_ACTIONS, observe) +} + /// Deterministic core game driver shared by the win-rate suite and the perf /// gate. Builds the two-player state, installs measurement-mode AI configs, and /// loops `run_ai_actions` until the action stream is empty or `action_cap` total @@ -555,6 +629,25 @@ pub(crate) fn drive_game( seed: u64, difficulty: AiDifficulty, action_cap: usize, +) -> (Option, u32) { + // Delegate with a no-op observer. The `&mut dyn FnMut` closure is called once + // per `run_ai_actions` *batch* (a batch spans many engine applies), so at + // measurement granularity this is perf-neutral vs the historical body — the + // unchanged `ai-perf-gate` baseline is the witness. + drive_game_observed(payload, seed, difficulty, action_cap, &mut |_, _| {}) +} + +/// [`drive_game`] with an observer seam: `observe(&state, &ai_session)` fires +/// after every `run_ai_actions` batch. The observer receives an immutable +/// `&GameState` (read-only by construction) and the per-game `AiSession` p0's +/// planner consumes. With a no-op closure the results are byte-identical to the +/// historical `drive_game` body. +pub(crate) fn drive_game_observed( + payload: &DeckPayload, + seed: u64, + difficulty: AiDifficulty, + action_cap: usize, + observe: &mut dyn FnMut(&GameState, &std::sync::Arc), ) -> (Option, u32) { let mut state = GameState::new_two_player(seed); load_deck_into_state(&mut state, payload); @@ -578,6 +671,7 @@ pub(crate) fn drive_game( &mut ai_rng, &ai_session, ); + observe(&state, &ai_session); if results.is_empty() { break; } @@ -793,4 +887,26 @@ mod tests { assert_eq!(status, SuiteStatus::Fail); assert!(reason.unwrap().contains("Wilson 95% CI")); } + + /// Observer seam is inert: for the same `(payload, seed)`, a no-op observer + /// yields an identical `(winner, turns)` to the un-observed driver. Uses an + /// empty `DeckPayload` (both libraries empty → deterministic draw-from-empty + /// loss), so the test needs no card database and runs in CI. + /// + /// Scope caveat: `drive_game` IS `drive_game_observed` with a no-op closure, + /// so both calls execute the same code — this catches nondeterminism in the + /// shared driver, not observer-induced drift (impossible by construction: + /// the observer receives only `&GameState`). The load-bearing inertness + /// evidence is the unchanged duel-suite win-rate baseline with harvest off. + #[test] + fn observer_seam_is_inert_for_noop_observer() { + let payload = DeckPayload::default(); + let seed = 4242; + let baseline = drive_game(&payload, seed, AiDifficulty::Easy, 200); + let observed = drive_game_observed(&payload, seed, AiDifficulty::Easy, 200, &mut |_, _| {}); + assert_eq!( + baseline, observed, + "no-op observer must not perturb (winner, turns)" + ); + } } diff --git a/crates/phase-ai/src/eval.rs b/crates/phase-ai/src/eval.rs index 7060b53790..1f76fba211 100644 --- a/crates/phase-ai/src/eval.rs +++ b/crates/phase-ai/src/eval.rs @@ -166,6 +166,59 @@ impl EvaluationBreakdown { } } +/// Single-authority **unweighted** feature vector for the tactical board eval — +/// the Texel train/serve invariant. `evaluate_state_breakdown` is defined as +/// `evaluate_features(..)? × weights`, so a feature harvested for offline weight +/// fitting is byte-for-byte the value that multiplies the corresponding weight at +/// serve time (see `crate::duel_suite::harvest::FeatureRow`, which extends this +/// with the three strategic dimensions from `evaluate_with_strategy`). +/// +/// Every field except `energy_offset` is a raw (self − opponent) differential +/// that pairs with one `EvalWeights` field. `energy_offset` is a fixed-coefficient +/// serve-time offset (`energy × 0.1`, CR 122.1) added **after** weighting, so it +/// is excluded from [`EvalFeatures::weighted_total`] — see the energy contract in +/// `evaluate_state_breakdown`. +/// +/// The full fitted serve vector is `EvalFeatures` (minus `energy_offset`) plus +/// `zone_bonus` (`zone_quality`), `SynergyGraph::board_synergy_bonus` (`synergy`), +/// and `card_advantage::differential` (folded into `card_advantage` alongside +/// `card_advantage_breakdown`). The two unfitted serve-time terms are +/// `energy_offset` (this fixed offset) and `threat_adjustment` (a heuristic with +/// no `EvalWeights` field). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct EvalFeatures { + pub life: f64, + pub board_presence: f64, + pub board_power: f64, + pub board_toughness: f64, + pub hand_size: f64, + pub aggression: f64, + /// Unweighted non-creature-permanent differential — the `nc_diff` term that + /// the `card_advantage` weight multiplies in the tactical breakdown. The serve + /// `card_advantage` feature also folds in `card_advantage::differential`; + /// see `FeatureRow::extract`. + pub card_advantage_breakdown: f64, + /// Fixed-coefficient energy offset (`energy × 0.1`). Added after weighting, so + /// excluded from `weighted_total`. + pub energy_offset: f64, +} + +impl EvalFeatures { + /// Weighted sum of every tactical feature **excluding** `energy_offset` (the + /// fixed serve-time offset added after weighting). Holds + /// `breakdown.total() == features.weighted_total(&w) + features.energy_offset` + /// by construction. + pub fn weighted_total(&self, w: &EvalWeights) -> f64 { + self.life * w.life + + self.board_presence * w.board_presence + + self.board_power * w.board_power + + self.board_toughness * w.board_toughness + + self.hand_size * w.hand_size + + self.aggression * w.aggression + + self.card_advantage_breakdown * w.card_advantage + } +} + pub fn strategic_intent(state: &GameState, player: PlayerId) -> StrategicIntent { let opponents = players::opponents(state, player); if opponents.is_empty() { @@ -307,11 +360,18 @@ pub fn evaluate_for_planner( } } -pub fn evaluate_state_breakdown( - state: &GameState, - player: PlayerId, - weights: &EvalWeights, -) -> Result { +/// Extract the **unweighted** tactical feature vector from `player`'s +/// perspective. Single authority for the feature math shared by the serve-time +/// weighting ([`evaluate_state_breakdown`]) and offline Texel harvesting +/// (`crate::duel_suite::harvest::FeatureRow::extract`). +/// +/// Terminal short-circuits are identical to [`evaluate_state_breakdown`]: a +/// game-over / lethal / all-opponents-dead position returns `Err(terminal_score)` +/// rather than a feature vector, so harvesting skips label-leaking terminal +/// positions by construction. Both the 2-player and multiplayer (threat-weighted) +/// aggregations are covered — the harvested value is whatever multiplies the +/// weight, so one extractor is path-agnostic. +pub fn evaluate_features(state: &GameState, player: PlayerId) -> Result { // Check for game over if let WaitingFor::GameOver { winner } = &state.waiting_for { return Err(match winner { @@ -337,7 +397,7 @@ pub fn evaluate_state_breakdown( return Err(WIN_SCORE); } - let mut breakdown = EvaluationBreakdown::default(); + let mut features = EvalFeatures::default(); let opp_count = opponents.len().max(1) as f64; // For multiplayer (3+), use threat-weighted opponent scoring @@ -369,19 +429,17 @@ pub fn evaluate_state_breakdown( } // Life differential (against threat-weighted opponent) - breakdown.life = (p.life as f64 - weighted_opp_life) * weights.life; + features.life = p.life as f64 - weighted_opp_life; let (my_creatures, my_power, my_toughness, my_nc) = board_stats(state, player); - breakdown.board_presence = - (my_creatures as f64 - weighted_opp_creatures) * weights.board_presence; - breakdown.board_power = (my_power as f64 - weighted_opp_power) * weights.board_power; - breakdown.board_toughness = - (my_toughness as f64 - weighted_opp_toughness) * weights.board_toughness; - breakdown.hand_size = (p.hand.len() as f64 - weighted_opp_hand) * weights.hand_size; - breakdown.card_advantage = (my_nc as f64 - weighted_opp_nc) * weights.card_advantage; + features.board_presence = my_creatures as f64 - weighted_opp_creatures; + features.board_power = my_power as f64 - weighted_opp_power; + features.board_toughness = my_toughness as f64 - weighted_opp_toughness; + features.hand_size = p.hand.len() as f64 - weighted_opp_hand; + features.card_advantage_breakdown = my_nc as f64 - weighted_opp_nc; if p.life as f64 > weighted_opp_life && my_power > 0 { - breakdown.aggression = my_power as f64 * weights.aggression; + features.aggression = my_power as f64; } } else { // 2-player path: original logic (no threat weighting overhead) @@ -403,29 +461,53 @@ pub fn evaluate_state_breakdown( } let avg_opp_life = total_opp_life as f64 / opp_count; - breakdown.life = (p.life as f64 - avg_opp_life) * weights.life; + features.life = p.life as f64 - avg_opp_life; let (my_creatures, my_power, my_toughness, my_nc) = board_stats(state, player); - breakdown.board_presence = - (my_creatures - total_opp_creatures) as f64 * weights.board_presence; - breakdown.board_power = (my_power - total_opp_power) as f64 * weights.board_power; - breakdown.board_toughness = - (my_toughness - total_opp_toughness) as f64 * weights.board_toughness; + features.board_presence = (my_creatures - total_opp_creatures) as f64; + features.board_power = (my_power - total_opp_power) as f64; + features.board_toughness = (my_toughness - total_opp_toughness) as f64; let avg_opp_hand = total_opp_hand_size as f64 / opp_count; - breakdown.hand_size = (p.hand.len() as f64 - avg_opp_hand) * weights.hand_size; + features.hand_size = p.hand.len() as f64 - avg_opp_hand; let avg_opp_nc = total_opp_nc as f64 / opp_count; - breakdown.card_advantage = (my_nc as f64 - avg_opp_nc) * weights.card_advantage; + features.card_advantage_breakdown = my_nc as f64 - avg_opp_nc; if p.life as f64 > avg_opp_life && my_power > 0 { - breakdown.aggression = my_power as f64 * weights.aggression; + features.aggression = my_power as f64; } } // CR 122.1: Energy counters are a minor resource — value each energy point - // as a small fraction of a card (comparable to scry). - breakdown.hand_size += p.energy as f64 * 0.1; + // as a small fraction of a card (comparable to scry). Fixed-coefficient + // offset applied AFTER weighting (see `evaluate_state_breakdown`), so it lives + // on `EvalFeatures` separately rather than folded into a weighted feature. + features.energy_offset = p.energy as f64 * 0.1; + + Ok(features) +} + +pub fn evaluate_state_breakdown( + state: &GameState, + player: PlayerId, + weights: &EvalWeights, +) -> Result { + let features = evaluate_features(state, player)?; + let mut breakdown = EvaluationBreakdown { + life: features.life * weights.life, + board_presence: features.board_presence * weights.board_presence, + board_power: features.board_power * weights.board_power, + board_toughness: features.board_toughness * weights.board_toughness, + hand_size: features.hand_size * weights.hand_size, + aggression: features.aggression * weights.aggression, + card_advantage: features.card_advantage_breakdown * weights.card_advantage, + }; + + // CR 122.1: energy is a fixed-coefficient offset added AFTER weighting, so + // `EvalFeatures::weighted_total` excludes it and it lands on `hand_size` here + // exactly as the historical `breakdown.hand_size += p.energy * 0.1` did. + breakdown.hand_size += features.energy_offset; Ok(breakdown) } @@ -800,6 +882,98 @@ mod tests { ); } + /// Row 1: `evaluate_state_breakdown` must equal `evaluate_features × weights` + /// with the energy offset added exactly once, AFTER weighting. With + /// `energy > 0` a regression that either drops the refactor or double-counts + /// energy diverges by a detectable margin. + #[test] + fn breakdown_total_equals_weighted_features_plus_energy() { + let mut state = make_state(); + state.turn_number = 5; // mid phase + state.players[0].life = 18; + state.players[1].life = 11; + add_creature(&mut state, PlayerId(0), 4, 4, vec![]); + add_creature(&mut state, PlayerId(0), 2, 3, vec![]); + add_creature(&mut state, PlayerId(1), 3, 2, vec![]); + state.players[0].energy = 7; // non-vacuous energy_offset + + let weights = EvalWeightSet::learned().mid; + let features = evaluate_features(&state, PlayerId(0)).expect("mid-game is non-terminal"); + assert!( + features.energy_offset > 0.0, + "energy term must be non-vacuous" + ); + + let breakdown = evaluate_state_breakdown(&state, PlayerId(0), &weights) + .expect("mid-game is non-terminal"); + + assert!( + (breakdown.total() - (features.weighted_total(&weights) + features.energy_offset)) + .abs() + < 1e-9, + "breakdown.total()={} must equal weighted_total + energy_offset={}", + breakdown.total(), + features.weighted_total(&weights) + features.energy_offset, + ); + } + + /// Row 1 hostile: terminal states short-circuit identically in both the + /// feature extractor and the weighted breakdown (GameOver + lethal-life). + #[test] + fn features_and_breakdown_agree_on_terminal_short_circuits() { + let weights = EvalWeights::default(); + + let mut over = make_state(); + over.waiting_for = WaitingFor::GameOver { + winner: Some(PlayerId(0)), + }; + assert_eq!( + evaluate_features(&over, PlayerId(0)).unwrap_err(), + evaluate_state_breakdown(&over, PlayerId(0), &weights).unwrap_err(), + ); + assert_eq!( + evaluate_features(&over, PlayerId(1)).unwrap_err(), + evaluate_state_breakdown(&over, PlayerId(1), &weights).unwrap_err(), + ); + + let mut lethal = make_state(); + lethal.players[0].life = 0; + assert_eq!( + evaluate_features(&lethal, PlayerId(0)).unwrap_err(), + LOSS_SCORE, + ); + assert_eq!( + evaluate_features(&lethal, PlayerId(0)).unwrap_err(), + evaluate_state_breakdown(&lethal, PlayerId(0), &weights).unwrap_err(), + ); + } + + /// Row 1 hostile: the identity also holds on a 3-player threat-weighted + /// position (the multiplayer aggregation branch), with energy non-zero. + #[test] + fn breakdown_identity_holds_for_threat_weighted_multiplayer() { + let mut state = GameState::new(engine::types::format::FormatConfig::free_for_all(), 3, 42); + state.turn_number = 9; // late phase + add_creature(&mut state, PlayerId(0), 3, 3, vec![]); + add_creature(&mut state, PlayerId(1), 5, 5, vec![]); + add_creature(&mut state, PlayerId(2), 1, 1, vec![]); + state.players[0].energy = 3; + + let weights = EvalWeightSet::learned().late; + let features = evaluate_features(&state, PlayerId(0)).expect("non-terminal"); + let breakdown = + evaluate_state_breakdown(&state, PlayerId(0), &weights).expect("non-terminal"); + + assert!( + (breakdown.total() - (features.weighted_total(&weights) + features.energy_offset)) + .abs() + < 1e-9, + "multiplayer identity must hold: {} vs {}", + breakdown.total(), + features.weighted_total(&weights) + features.energy_offset, + ); + } + #[test] fn opponent_creature_threat_value_rejects_wrong_relation_zone_and_type() { let mut state = GameState::new( diff --git a/crates/phase-ai/src/planner/mod.rs b/crates/phase-ai/src/planner/mod.rs index 2ce6192a0e..da39d04e6d 100644 --- a/crates/phase-ai/src/planner/mod.rs +++ b/crates/phase-ai/src/planner/mod.rs @@ -7,6 +7,7 @@ use engine::ai_support::{ }; use engine::game::engine::apply_as_current_for_simulation; use engine::game::players; +use engine::types::actions::GameAction; use engine::types::counter::{has_positive_counters, positive_counter_entries}; use engine::types::game_state::{DayNight, GameState, WaitingFor}; use engine::types::keywords::Keyword; @@ -351,6 +352,25 @@ pub struct TtEntry { /// never binds in practice; it mirrors the `eval_cache` 256-entry guard idiom. const TT_CAPACITY: usize = 4096; +/// Ply depth covered by the killer-move table. `max_depth` is <= 3 on every +/// difficulty/platform (see `config.rs`), so 8 leaves ample margin; killers +/// recorded past this ply are simply not tracked (inert, never wrong). +const MAX_KILLER_PLY: usize = 8; + +/// Per-rung iterative-deepening witness: did the depth-N rung complete, and how +/// much of its node budget it consumed. AI-local search-*quality* record (not an +/// engine perf counter — `perf_counters.rs` is out of scope). A saturated rung +/// is one where `nodes_used >= max_nodes` (`SearchBudget::tick` increments +/// unconditionally while `exhausted()` checks `>=`, so the counter can equal or +/// overshoot the cap by one). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RungStat { + pub depth: u32, + pub completed: bool, + pub nodes_used: u32, + pub max_nodes: u32, +} + fn hash_waiting_for(waiting_for: &WaitingFor, hasher: &mut impl Hasher) { let value = serde_json::to_value(waiting_for).expect("WaitingFor serializes"); hash_json_value(&value, hasher); @@ -469,6 +489,25 @@ pub struct PlannerServices<'a> { /// without threading `SearchBudget` everywhere. Populated from the caller's /// time budget at construction time; `Deadline::none()` when no budget. pub deadline: engine::util::Deadline, + /// CR-free search heuristic: the last 2 moves (indexed by ply-from-root) that + /// caused a beta cutoff. Decision-scoped like the TT — rebuilt per + /// determinized sample (`score_candidates_core`), so killers never leak + /// across sampled worlds. `GameAction` is `PartialEq` but not `Hash`, so a + /// fixed 2-slot array compared by `==` is the idiomatic fit (no serialized + /// key on the hot path; that key exists for cross-sample HashMap merging). + /// `pub(crate)` for cross-module witness tests (mirrors `tt_hits`). + pub(crate) killers: [[Option; 2]; MAX_KILLER_PLY], + /// Witness: beta cutoffs recorded this decision. Not an engine perf counter; + /// the reach-guard that a cutoff branch actually executed (see V1/V8 tests). + pub beta_cutoffs: u32, + /// Witness: times a killer was present in a beam and rotated forward. The + /// direct efficacy signal for the within-beam-vs-rescue follow-up decision. + pub killer_orderings: u32, + /// Witness: one entry per iterative-deepening rung, pushed by + /// `run_iterative_deepening`. Evidence that ordering work converts to + /// realized search depth (did the deep rung complete, with how much + /// node headroom). + pub rung_stats: Vec, } impl<'a> PlannerServices<'a> { @@ -545,6 +584,10 @@ impl<'a> PlannerServices<'a> { tt_hits: 0, candidate_cache: HashMap::new(), deadline, + killers: Default::default(), + beta_cutoffs: 0, + killer_orderings: 0, + rung_stats: Vec::new(), } } @@ -734,6 +777,51 @@ impl<'a> PlannerServices<'a> { } } + /// Record `action` as the primary killer at `ply` using standard two-slot + /// replacement: shift slot 0 -> slot 1, then store into slot 0. No-op when + /// `action` already occupies slot 0 (keeps slot 1 as the second-best killer) + /// or when `ply` exceeds the tracked depth. + fn record_killer(&mut self, ply: usize, action: &GameAction) { + if ply >= MAX_KILLER_PLY { + return; + } + if self.killers[ply][0].as_ref() == Some(action) { + return; // already the primary killer — don't evict the secondary + } + self.killers[ply][1] = self.killers[ply][0].take(); + self.killers[ply][0] = Some(action.clone()); + } + + /// Reorder-only killer heuristic: stable-rotate candidates whose action + /// matches a killer slot at `ply` to the front of the already-truncated beam + /// (slot 0 before slot 1), preserving relative order among killers and among + /// non-killers. **Scores are never modified** — killers change only visit + /// order, so the value function (`ranked.score` uses at planner/mod.rs and + /// search.rs) is untouched. Increments `killer_orderings` only when a killer + /// was actually present in the beam. + fn order_killers_first(&mut self, ply: usize, ranked: &mut [RankedCandidate]) { + if ply >= MAX_KILLER_PLY { + return; + } + let killers = &self.killers[ply]; + // 0 = primary killer, 1 = secondary killer, 2 = non-killer. A stable sort + // on this key rotates killers to the front while preserving the relative + // order of every other candidate (and of the two killer slots). + let rank = |r: &RankedCandidate| -> u8 { + if killers[0].as_ref() == Some(&r.candidate.action) { + 0 + } else if killers[1].as_ref() == Some(&r.candidate.action) { + 1 + } else { + 2 + } + }; + if ranked.iter().any(|r| rank(r) < 2) { + ranked.sort_by_key(rank); + self.killer_orderings += 1; + } + } + /// Evaluate state with both tactical and strategic dimensions. /// Tactical eval (evaluate_state) is context-free and uses adjusted weights. /// Strategic dimensions (synergy, zone quality, card advantage) use AiContext. @@ -1074,10 +1162,12 @@ pub struct BeamContinuationPlanner { } impl BeamContinuationPlanner { - fn search_value( + #[allow(clippy::too_many_arguments)] + pub(crate) fn search_value( &self, state: &GameState, depth: u32, + ply: usize, mut alpha: f64, mut beta: f64, services: &mut PlannerServices<'_>, @@ -1115,7 +1205,7 @@ impl BeamContinuationPlanner { let node_player = state.waiting_for.acting_player(); let is_maximizing = node_player.is_none_or(|player| player == services.ai_player); let scoring_player = node_player.unwrap_or(services.ai_player); - let ranked = rank_candidates( + let mut ranked = rank_candidates( ctx.candidates.clone(), // Interior beam node: `search_value` is always entered ≥1 ply below // the decision root, so move-ordering scoring runs in lookahead. @@ -1130,6 +1220,10 @@ impl BeamContinuationPlanner { }, services.config.search.max_branching as usize, ); + // Move ordering: try killer moves (prior beta-cutoff causers at this ply) + // first to maximize alpha-beta pruning. Reorder-only — candidate scores + // are never mutated (they leak into the value function below). + services.order_killers_first(ply, &mut ranked); // Alpha-beta pruning: explicit loop for early cutoff. // Move ordering from rank_candidates (best-first) maximizes pruning effectiveness. @@ -1149,7 +1243,7 @@ impl BeamContinuationPlanner { let Some(sim) = services.apply_candidate(state, &ranked.candidate) else { continue; }; - let value = self.search_value(&sim, depth - 1, alpha, beta, services, budget) + let value = self.search_value(&sim, depth - 1, ply + 1, alpha, beta, services, budget) + (ranked.score * 0.05); if is_maximizing { @@ -1161,6 +1255,10 @@ impl BeamContinuationPlanner { } if alpha >= beta { + // Beta cutoff: record the cutting move as a killer for this ply + // and count the cutoff (both witness-only; scores untouched). + services.beta_cutoffs += 1; + services.record_killer(ply, &ranked.candidate.action); break; } } @@ -1191,9 +1289,12 @@ impl ContinuationPlanner for BeamContinuationPlanner { if self.depth == 0 { services.evaluate_state_quiesced(state) } else { + // Ply-from-root starts at 0 for the rung root: a killer recorded at + // ply 1 of one rung is reused at ply 1 of the next (chess convention). self.search_value( state, self.depth, + 0, f64::NEG_INFINITY, f64::INFINITY, services, @@ -1315,6 +1416,139 @@ mod tests { assert!(services.context.deadline.remaining().is_none()); } + /// Serve reconstruction ≡ planner leaf eval. The harvested `FeatureRow` + /// weighted by the archetype-adjusted weights the planner actually applies, + /// plus the TWO serve-time carve-outs (`energy_offset`, `threat_adjustment`), + /// must equal `evaluate_with_strategy` to 1e-9. This pins the train/serve + /// invariant: a future `evaluate_with_strategy` term without a matching + /// `FeatureRow` field would break this identity loudly. Runs a reach-guard + /// pair — one config where `threat_adjustment` is provably nonzero, one where + /// it is zero — and asserts the energy term is non-vacuous (`p0.energy > 0`). + #[test] + fn serve_reconstruction_equals_planner_leaf_eval() { + use crate::context::AiContext; + use crate::deck_profile::DeckArchetype; + use crate::duel_suite::harvest::FeatureRow; + use crate::threat_profile::{ThreatProbabilities, ThreatProfile}; + use engine::game::DeckEntry; + use engine::types::card::CardFace; + use engine::types::card_type::{CardType, CoreType}; + + // A small aggressive deck so `adjusted_weights` genuinely differs from the + // base set (exercises the archetype-adjusted path, not a no-op remap). + let deck: Vec = (0..8) + .map(|i| DeckEntry { + card: CardFace { + name: format!("Goblin {i}"), + card_type: CardType { + core_types: vec![CoreType::Creature], + subtypes: vec!["Goblin".to_string()], + ..Default::default() + }, + ..Default::default() + }, + count: 4, + }) + .collect(); + + // Mid-game, non-terminal state from p0's perspective, energy > 0, and NO + // lands for p0 so `available_mana(p0) <= 1` makes the counter-tapout + // penalty reachable. + let mut state = make_state(); + state.turn_number = 5; + state.players[0].life = 17; + state.players[1].life = 12; + state.players[0].energy = 4; + for (owner, power, toughness) in [ + (PlayerId(0), 2, 1), + (PlayerId(0), 3, 3), + (PlayerId(1), 4, 4), + ] { + let card_id = CardId(state.next_object_id); + let id = create_object( + &mut state, + card_id, + owner, + "Body".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.power = Some(power); + obj.toughness = Some(toughness); + } + + let config = create_config(AiDifficulty::Hard, Platform::Native); + let policies = crate::policies::PolicyRegistry::shared(); + + // Case A: opponent_threat present with a high counterspell probability → + // threat_adjustment is nonzero. + let mut ctx_threat = AiContext::analyze_for_player( + &deck, + &config.weights, + &config.archetype_multipliers, + PlayerId(0), + ); + ctx_threat.opponent_threat = Some(ThreatProfile { + probabilities: ThreatProbabilities { + counterspell: 0.9, + ..Default::default() + }, + opponent_archetype: DeckArchetype::Control, + category_pools: Default::default(), + pool_size: 0, + hand_size: 0, + }); + let services = PlannerServices::new(PlayerId(0), &config, policies, ctx_threat); + + let turn = state.turn_number; + let weights = services.context.adjusted_weights.for_turn(turn); + let row = FeatureRow::extract(&state, &services.context.session, PlayerId(0)) + .expect("mid-game state is non-terminal"); + let threat_adj = services.threat_adjustment(&state); + let reconstructed = row.weighted_total(weights) + row.energy_offset + threat_adj; + let planner_eval = services.evaluate_with_strategy(&state); + + assert!( + row.energy_offset > 0.0, + "energy term must be non-vacuous (p0.energy > 0)" + ); + assert!( + threat_adj.abs() > 0.0, + "reach-guard: threat_adjustment must be provably nonzero here, got {threat_adj}" + ); + assert!( + (planner_eval - reconstructed).abs() < 1e-9, + "serve reconstruction diverged: planner={planner_eval} reconstructed={reconstructed}" + ); + + // Case B: no opponent_threat → threat_adjustment == 0, identity still holds + // (the reach-guard's zero pole). + let ctx_none = AiContext::analyze_for_player( + &deck, + &config.weights, + &config.archetype_multipliers, + PlayerId(0), + ); + let services_none = PlannerServices::new(PlayerId(0), &config, policies, ctx_none); + let weights_none = services_none.context.adjusted_weights.for_turn(turn); + let row_none = FeatureRow::extract(&state, &services_none.context.session, PlayerId(0)) + .expect("non-terminal"); + let threat_adj_none = services_none.threat_adjustment(&state); + let reconstructed_none = row_none.weighted_total(weights_none) + row_none.energy_offset; + let planner_eval_none = services_none.evaluate_with_strategy(&state); + + assert_eq!( + threat_adj_none, 0.0, + "no opponent_threat ⇒ threat_adjustment is exactly zero" + ); + assert!( + (planner_eval_none - reconstructed_none).abs() < 1e-9, + "serve reconstruction (no threat) diverged: planner={planner_eval_none} \ + reconstructed={reconstructed_none}" + ); + } + #[test] fn candidate_cache_key_hashes_waiting_for_payload() { let state = make_state(); @@ -2241,6 +2475,7 @@ mod tests { let value = planner.search_value( &state, 2, + 0, f64::NEG_INFINITY, f64::INFINITY, &mut services, @@ -2298,6 +2533,7 @@ mod tests { let value = planner.search_value( &state, 2, + 0, f64::NEG_INFINITY, f64::INFINITY, &mut services, @@ -2338,6 +2574,7 @@ mod tests { let _ = planner.search_value( &state, 1, + 0, f64::NEG_INFINITY, f64::INFINITY, &mut services, @@ -2361,4 +2598,219 @@ mod tests { "measurement deadline is none() -> expired() is always false" ); } + + // ---- U2: move ordering (killers) + witness counters ---- + + fn ranked_candidate(action: GameAction, score: f64) -> RankedCandidate { + RankedCandidate { + candidate: CandidateAction { + action, + metadata: ActionMetadata { + actor: Some(PlayerId(0)), + tactical_class: TacticalClass::Pass, + }, + }, + score, + } + } + + // V1: a beta cutoff records the cutting move as the ply's primary killer. + #[test] + fn beta_cutoff_records_cutting_action_as_killer() { + let mut state = make_state(); + add_mana_land(&mut state, PlayerId(0)); // >= 2 candidates (tap + pass) + let config = create_config(AiDifficulty::Hard, Platform::Native); + let policies = PolicyRegistry::default(); + let mut services = hard_services(&config, &policies); + + // Reconstruct the exact interior ordering the search visits (killers are + // empty, so `order_killers_first` is a no-op). Under a collapsed window + // the cut fires after the FIRST applicable candidate in rank order. + let ctx = services.build_decision_context(&state); + let scoring_player = state + .waiting_for + .acting_player() + .unwrap_or(services.ai_player); + let expected_ranked = rank_candidates( + ctx.candidates.clone(), + |c| services.tactical_score(&state, &ctx, c, scoring_player, SearchDepth::Lookahead), + config.search.max_branching as usize, + ); + let cutting = expected_ranked + .iter() + .find(|r| apply_candidate(&state, &r.candidate).is_some()) + .map(|r| r.candidate.action.clone()) + .expect("fixture must have an applicable candidate"); + + let planner = BeamContinuationPlanner { + depth: 1, + rollout_depth: 1, + }; + let mut budget = SearchBudget::new(100_000); + // Collapsed window (alpha == beta) forces alpha >= beta after the first + // applied candidate — a deterministic cutoff. + let _ = planner.search_value(&state, 1, 0, 0.0, 0.0, &mut services, &mut budget); + + // Reach-guard: the cutoff branch actually executed (non-vacuous). + assert!( + services.beta_cutoffs > 0, + "reach-guard: a beta cutoff must have fired" + ); + // Revert-failing: removing `record_killer` at the break leaves this None. + assert_eq!( + services.killers[0][0].as_ref(), + Some(&cutting), + "the beta-cutting action must be recorded as the ply-0 primary killer" + ); + } + + // V2: killer ordering rotates matching candidates to the front of the beam + // WITHOUT mutating any score, and only counts when a killer is present. + #[test] + fn killer_ordering_reorders_within_beam_without_touching_scores() { + let config = create_config(AiDifficulty::Hard, Platform::Native); + let policies = PolicyRegistry::default(); + + // Beam of 3, already best-first by score; the killer is ranked LAST. + let control = vec![ + ranked_candidate(GameAction::PassPriority, 3.0), + ranked_candidate(GameAction::CancelCast, 2.0), + ranked_candidate(GameAction::DeclineShortcut, 1.0), + ]; + // Full (action -> score-bits) map — every key AND value, not set equality. + let score_map = |b: &[RankedCandidate]| -> std::collections::BTreeMap { + b.iter() + .map(|r| (format!("{:?}", r.candidate.action), r.score.to_bits())) + .collect() + }; + + // Primary: last-ranked killer rotates to the front, scores untouched. + let mut services = hard_services(&config, &policies); + services.record_killer(0, &GameAction::DeclineShortcut); + let mut beam = control.clone(); + services.order_killers_first(0, &mut beam); + assert_eq!( + beam[0].candidate.action, + GameAction::DeclineShortcut, + "the seeded killer is rotated to the front" + ); + assert_eq!( + score_map(&beam), + score_map(&control), + "reorder is score-preserving (every action keeps its exact score)" + ); + assert_eq!( + services.killer_orderings, 1, + "a present killer counts as exactly one ordering" + ); + + // Sibling: with two killers, slot 0 must precede slot 1. + let mut services2 = hard_services(&config, &policies); + services2.record_killer(0, &GameAction::DeclineShortcut); // -> slot 0 + services2.record_killer(0, &GameAction::CancelCast); // -> slot 0, prev -> slot 1 + let mut beam2 = control.clone(); + services2.order_killers_first(0, &mut beam2); + assert_eq!( + beam2[0].candidate.action, + GameAction::CancelCast, + "slot-0 killer orders first" + ); + assert_eq!( + beam2[1].candidate.action, + GameAction::DeclineShortcut, + "slot-1 killer orders second" + ); + + // Hostile: a killer absent from the candidate set leaves order untouched + // (the `==` match miss path) and never counts an ordering. + let mut services3 = hard_services(&config, &policies); + services3.record_killer( + 0, + &GameAction::MulliganDecision { + choice: MulliganChoice::Keep, + }, + ); + let mut beam3 = control.clone(); + services3.order_killers_first(0, &mut beam3); + let actions = |b: &[RankedCandidate]| { + b.iter() + .map(|r| r.candidate.action.clone()) + .collect::>() + }; + assert_eq!( + actions(&beam3), + actions(&control), + "an absent killer leaves the beam order identical to control" + ); + assert_eq!( + services3.killer_orderings, 0, + "no killer present in the beam => no ordering counted" + ); + } + + // V7a: freshly constructed services carry no killers and zeroed witnesses. + // Combined with the per-sample construction site in `score_candidates_core`, + // this pins that every determinized sample starts clean (no cross-world leak). + #[test] + fn fresh_services_has_clean_killers_and_witnesses() { + let config = create_config(AiDifficulty::Hard, Platform::Native); + let policies = PolicyRegistry::default(); + let services = hard_services(&config, &policies); + assert!( + services + .killers + .iter() + .all(|ply| ply.iter().all(Option::is_none)), + "fresh services must have no killers" + ); + assert_eq!(services.beta_cutoffs, 0); + assert_eq!(services.killer_orderings, 0); + assert!(services.rung_stats.is_empty()); + } + + // V8: the cutoff counter tracks real cutoffs, not spurious increments. + #[test] + fn beta_cutoff_counter_is_honest() { + let mut state = make_state(); + add_mana_land(&mut state, PlayerId(0)); // >= 2 candidates + let policies = PolicyRegistry::default(); + + // Positive: collapsed window at a maximizing node forces a cutoff. + let config = create_config(AiDifficulty::Hard, Platform::Native); + let mut services = hard_services(&config, &policies); + let mut budget = SearchBudget::new(100_000); + let planner = BeamContinuationPlanner { + depth: 1, + rollout_depth: 1, + }; + let _ = planner.search_value(&state, 1, 0, 0.0, 0.0, &mut services, &mut budget); + assert!( + services.beta_cutoffs > 0, + "a real cutoff must increment the counter" + ); + + // Negative: `max_branching == 1` under a full (-inf, +inf) window can + // never cut — a single sibling cannot push alpha past beta. Counter == 0. + let mut narrow_config = create_config(AiDifficulty::Hard, Platform::Native); + narrow_config.search.max_branching = 1; + let mut services_n = hard_services(&narrow_config, &policies); + let mut budget_n = SearchBudget::new(100_000); + let planner_n = BeamContinuationPlanner { + depth: 2, + rollout_depth: 1, + }; + let _ = planner_n.search_value( + &state, + 2, + 0, + f64::NEG_INFINITY, + f64::INFINITY, + &mut services_n, + &mut budget_n, + ); + assert_eq!( + services_n.beta_cutoffs, 0, + "a single-width full-window search cannot produce a cutoff" + ); + } } diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 29cb60b770..1fed1f37af 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -29,7 +29,7 @@ use crate::mana_colors::demand_aware_single_color; use crate::plan::PlanSnapshot; use crate::planner::{ apply_candidate, BeamContinuationPlanner, ContinuationPlanner, PlannerServices, - RankedCandidate, SearchBudget, + RankedCandidate, RungStat, SearchBudget, }; use crate::policies::context::{PolicyContext, SearchDepth}; use crate::policies::copy_value::score_legend_rule_keep; @@ -2029,81 +2029,7 @@ fn score_candidates_core( }); ranked.truncate(branching); - // Iterative deepening: rung 0 (quiesced eval per candidate) -> ceiling. - // Return the deepest *fully completed* rung. The deepest rung reproduces - // origin/main's fixed-depth pass; the TT (per-decision, on `services`) - // accelerates the re-search of transposing subtrees across rungs. - let ceiling: u32 = match config.search.planner_mode { - PlannerMode::BeamOnly => 0, - PlannerMode::BeamPlusRollout => config.search.max_depth.saturating_sub(1), - }; - - // No-regression floor == origin/main's deadline collapse: tactical-only for - // every candidate. Overwritten by each completed rung; returned as-is only - // if not even rung 0 is entered (deadline pre-expired), which reproduces - // origin/main's zero-apply collapse exactly. - let mut best_scored: Vec<(GameAction, f64)> = ranked - .iter() - .map(|r| (r.candidate.action.clone(), r.score * tactical_weight)) - .collect(); - - for iter_depth in 0..=ceiling { - // Guard EVERY rung (incl. rung 0) at entry. Interactive: a pre-expired - // deadline returns the tactical-only floor with zero applies (== - // origin/main). Measurement: services.deadline is none() => never - // expires => full fixed ceiling => deterministic. - if services.deadline.expired() { - break; - } - // Fresh node budget per rung sharing the one services.deadline (none() - // in measurement, so this single constructor is correct for both modes). - // The deepest rung thus gets the full max_nodes just like origin/main's - // single pass. - let mut budget = - SearchBudget::with_deadline(config.search.max_nodes, services.deadline); - let mut planner = BeamContinuationPlanner { - depth: iter_depth, - rollout_depth: config.search.rollout_depth, - }; - - let mut rung_scored = Vec::with_capacity(ranked.len()); - let mut completed = true; - for r in &ranked { - // Rungs >= 1 may bail mid-rung (interior search is expensive) and - // discard the partial. Rung 0 is cheap (branching quiesced evals) - // and runs atomically once entered, so it is never left partial. - if iter_depth > 0 && services.deadline.expired() { - completed = false; - break; - } - let score = if let Some(sim) = apply_candidate(state, &r.candidate) { - let cont = planner.evaluate_after_action(&sim, &mut services, &mut budget); - cont + (r.score * tactical_weight) - } else { - // Action failed simulation — same penalty as origin/main so the - // AI prefers any valid alternative. - r.score - 1000.0 - }; - rung_scored.push((r.candidate.action.clone(), score)); - } - - // "Fully completed" also requires the deadline to be live after the - // LAST candidate: expiry mid-final-evaluation is invisible to the - // per-candidate entry check and would accept a rung whose tail score - // was truncated. Rung 0 stays exempt (atomic once entered — it is the - // no-regression floor, == origin/main's deadline collapse). Node-budget - // exhaustion deliberately does NOT discard: the deepest rung consuming - // its full `max_nodes` reproduces origin/main's single fixed-depth pass. - if completed && (iter_depth == 0 || !services.deadline.expired()) { - best_scored = rung_scored; // deepest fully-completed rung so far - } else { - break; - } - } - - let mut out = best_scored; - out.sort_by(|a, b| a.0.cmp_stable(&b.0)); - out + run_iterative_deepening(state, ranked, tactical_weight, config, &mut services) } else { // Heuristic-only scoring let mut out: Vec<_> = gated @@ -2124,6 +2050,168 @@ fn score_candidates_core( } } +/// Runs rung-0..=ceiling iterative deepening over the pre-ranked root beam. +/// Extracted from `score_candidates_core` so tests can construct +/// `PlannerServices`, run the loop, and inspect witness state (`rung_stats`, +/// killers, counters) — mirroring how `tt_hits` is observable via direct +/// `search_value` calls. The pre-rung tactical-only floor, the rung loop, and +/// the acceptance logic all live here; `score_candidates_core` just delegates. +/// +/// PV threading (D2) and the rung witness (D3) are the only additions over the +/// pre-extraction behavior; both are no-ops for `rung_stats`/ordering when the +/// beam is a single candidate or the killers are empty. +fn run_iterative_deepening( + state: &GameState, + mut ranked: Vec, + tactical_weight: f64, + config: &AiConfig, + services: &mut PlannerServices<'_>, +) -> Vec<(GameAction, f64)> { + // Iterative deepening: rung 0 (quiesced eval per candidate) -> ceiling. + // Return the deepest *fully completed* rung. The deepest rung reproduces + // origin/main's fixed-depth pass; the TT (per-decision, on `services`) + // accelerates the re-search of transposing subtrees across rungs. + let ceiling: u32 = match config.search.planner_mode { + PlannerMode::BeamOnly => 0, + PlannerMode::BeamPlusRollout => config.search.max_depth.saturating_sub(1), + }; + + // No-regression floor == origin/main's deadline collapse: tactical-only for + // every candidate. Overwritten by each completed rung; returned as-is only + // if not even rung 0 is entered (deadline pre-expired), which reproduces + // origin/main's zero-apply collapse exactly. + let mut best_scored: Vec<(GameAction, f64)> = ranked + .iter() + .map(|r| (r.candidate.action.clone(), r.score * tactical_weight)) + .collect(); + + for iter_depth in 0..=ceiling { + // Guard EVERY rung (incl. rung 0) at entry. Interactive: a pre-expired + // deadline returns the tactical-only floor with zero applies (== + // origin/main). Measurement: services.deadline is none() => never + // expires => full fixed ceiling => deterministic. + if services.deadline.expired() { + break; + } + // Fresh node budget per rung sharing the one services.deadline (none() + // in measurement, so this single constructor is correct for both modes). + // The deepest rung thus gets the full max_nodes just like origin/main's + // single pass. + let mut budget = SearchBudget::with_deadline(config.search.max_nodes, services.deadline); + let mut planner = BeamContinuationPlanner { + depth: iter_depth, + rollout_depth: config.search.rollout_depth, + }; + + let mut rung_scored = Vec::with_capacity(ranked.len()); + let mut completed = true; + for r in &ranked { + // Rungs >= 1 may bail mid-rung (interior search is expensive) and + // discard the partial. Rung 0 is cheap (branching quiesced evals) + // and runs atomically once entered, so it is never left partial. + if iter_depth > 0 && services.deadline.expired() { + completed = false; + break; + } + let score = if let Some(sim) = apply_candidate(state, &r.candidate) { + let cont = planner.evaluate_after_action(&sim, services, &mut budget); + cont + (r.score * tactical_weight) + } else { + // Action failed simulation — same penalty as origin/main so the + // AI prefers any valid alternative. + r.score - 1000.0 + }; + rung_scored.push((r.candidate.action.clone(), score)); + } + + // "Fully completed" also requires the deadline to be live after the + // LAST candidate: expiry mid-final-evaluation is invisible to the + // per-candidate entry check and would accept a rung whose tail score + // was truncated. Rung 0 stays exempt (atomic once entered — it is the + // no-regression floor, == origin/main's deadline collapse). Node-budget + // exhaustion deliberately does NOT discard: the deepest rung consuming + // its full `max_nodes` reproduces origin/main's single fixed-depth pass. + let accepted = completed && (iter_depth == 0 || !services.deadline.expired()); + + // D3: one witness per executed rung (completion + node headroom). A + // pre-expired deadline breaks at the entry guard above, so zero rungs + // execute and `rung_stats` stays empty — the honest "no search" trace. + services.rung_stats.push(RungStat { + depth: iter_depth, + completed: accepted, + nodes_used: budget.nodes_evaluated, + max_nodes: budget.max_nodes, + }); + + if accepted { + // D2: thread the principal variation into the NEXT rung. Gated to + // searched rungs (`iter_depth >= 1`): rung 0's argmax mixes quiesced + // eval with the tactical term, so rotating on it would change rung + // 1's order vs today. Rung 1 therefore provably sees today's + // ordering; divergence begins at rung 2, where it is a legitimate + // budget-allocation improvement (see `pv_argmax`). + if iter_depth >= 1 { + if let Some(pv) = pv_argmax(&rung_scored) { + rotate_pv_to_front(&mut ranked, pv); + } + } + best_scored = rung_scored; // deepest fully-completed rung so far + } else { + break; + } + } + + tracing::debug!( + rungs = services.rung_stats.len(), + completed = services.rung_stats.iter().filter(|r| r.completed).count(), + deepest = services.rung_stats.last().map_or(0, |r| r.depth), + nodes_used = services + .rung_stats + .iter() + .map(|r| r.nodes_used) + .sum::(), + beta_cutoffs = services.beta_cutoffs, + killer_orderings = services.killer_orderings, + "iterative deepening rung summary" + ); + + let mut out = best_scored; + out.sort_by(|a, b| a.0.cmp_stable(&b.0)); + out +} + +/// Deterministic principal-variation selection over a completed rung's scores. +/// Budget-allocation policy, not alpha-beta: root siblings share one per-rung +/// `SearchBudget` (constructed once per rung in `run_iterative_deepening`) and +/// each opens a fresh `(-inf, +inf)` window, so PV-first spends the shared pool +/// on the strongest known candidate before the tail starves — no alpha carries +/// between root siblings. +/// +/// NaN-safe: `unwrap_or(Equal)` defers to the `cmp_stable` total order so ties +/// and non-finite scores resolve deterministically, never a bare +/// `max_by(|a, b| a.partial_cmp(b).unwrap())`. +fn pv_argmax(rung_scored: &[(GameAction, f64)]) -> Option<&GameAction> { + rung_scored + .iter() + .max_by(|a, b| { + a.1.partial_cmp(&b.1) + .unwrap_or(Ordering::Equal) + .then_with(|| b.0.cmp_stable(&a.0)) // ties: cmp_stable decides + }) + .map(|(action, _)| action) +} + +/// Stable-rotate the candidate whose action equals `pv` to the front of +/// `ranked`, preserving the relative order of every other candidate. No-op when +/// `pv` is absent (e.g. it was the `-1000.0`-penalized illegal candidate that a +/// later rung will re-validate anyway). +fn rotate_pv_to_front(ranked: &mut Vec, pv: &GameAction) { + if let Some(idx) = ranked.iter().position(|r| &r.candidate.action == pv) { + let pv_candidate = ranked.remove(idx); + ranked.insert(0, pv_candidate); + } +} + /// Build AI context from the player's deck pool, or a neutral default if unavailable. fn build_ai_context_with_session( state: &GameState, @@ -6016,4 +6104,452 @@ mod tests { so its floor differs from the rung-0 quiesced baseline" ); } + + // ---- U2: PV threading + rung witnesses (drive `run_iterative_deepening`) ---- + + /// Rebuild the root beam exactly as `score_candidates_core` does (validate -> + /// gate -> tactical rank -> #4878 stable sort -> truncate) so tests can drive + /// `run_iterative_deepening` directly and inspect the witness state it leaves + /// on `services`. `&PlannerServices` — reads only (validate/tactical_score are + /// `&self`); the caller owns construction so it controls the deadline/TT. + fn build_root_beam(state: &GameState, services: &PlannerServices<'_>) -> Vec { + let ctx = build_decision_context(state); + let candidates = services.validate_candidates(state, ctx.candidates.clone()); + let gated = gate_candidates( + state, + &ctx, + candidates, + services.ai_player, + services.config, + &services.context, + ); + let mut ranked: Vec = gated + .iter() + .map(|g| { + let tactical = services.tactical_score( + state, + &ctx, + &g.candidate, + services.ai_player, + SearchDepth::Root, + ); + RankedCandidate { + candidate: g.candidate.clone(), + score: tactical + g.penalty, + } + }) + .collect(); + ranked.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(Ordering::Equal) + .then_with(|| a.candidate.action.cmp_stable(&b.candidate.action)) + }); + ranked.truncate(services.config.search.max_branching as usize); + ranked + } + + fn score_of(scored: &[(GameAction, f64)], action: &GameAction) -> f64 { + scored + .iter() + .find(|(a, _)| a == action) + .map(|(_, s)| *s) + .unwrap_or_else(|| panic!("action {action:?} absent from scored output")) + } + + /// Fixture with several cheap castable creatures + an opponent threat, so the + /// search tree has rich interior branching (subtrees far exceed a tiny node + /// cap => genuine budget starvation) AND a value gradient (casting a creature + /// beats passing, so the search argmax can differ from a pass-first beam). + fn starvation_state() -> GameState { + let mut state = make_state(); + state.lands_played_this_turn = 1; + let _opp = add_creature(&mut state, PlayerId(1), 3, 3); + for i in 0..4u64 { + let id = create_object( + &mut state, + CardId(900 + i), + PlayerId(0), + format!("Bear{i}"), + Zone::Hand, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.power = Some(2); + obj.toughness = Some(2); + obj.mana_cost = engine::types::mana::ManaCost::Cost { + shards: Vec::new(), + generic: 1, + }; + } + add_mana(&mut state, PlayerId(0), ManaType::Colorless, 6); + state + } + + /// Extract (PassPriority, first CastSpell) real candidates from `state`. + fn pass_and_first_cast(state: &GameState) -> (CandidateAction, CandidateAction) { + let ctx = build_decision_context(state); + let pass = ctx + .candidates + .iter() + .find(|c| matches!(c.action, GameAction::PassPriority)) + .cloned() + .expect("a PassPriority candidate exists at priority"); + let cast = ctx + .candidates + .iter() + .find(|c| matches!(c.action, GameAction::CastSpell { .. })) + .cloned() + .expect("a CastSpell candidate exists (creatures in hand + mana)"); + (pass, cast) + } + + // V5: empty-state equivalence — a BeamOnly (ceiling 0) run enters `search_value` + // zero times, so killers stay clean, both cutoff/ordering counters are 0, and + // exactly one rung witness (rung 0) is recorded. + #[test] + fn beam_only_run_is_search_value_free() { + let state = searchable_state(); + let policies = PolicyRegistry::shared(); + let mut config = create_config(AiDifficulty::Hard, Platform::Native).into_measurement(7); + config.search.planner_mode = PlannerMode::BeamOnly; // ceiling 0 + let mut services = PlannerServices::new_default(PlayerId(0), &config, policies); + let ranked = build_root_beam(&state, &services); + let out = run_iterative_deepening(&state, ranked, 0.1, &config, &mut services); + + assert!(!out.is_empty(), "rung 0 produces the floor"); + // Reach-guard: rung 0 ran (non-vacuous). + assert_eq!(services.rung_stats.len(), 1, "exactly rung 0 executed"); + assert!(services.rung_stats[0].completed); + assert_eq!(services.rung_stats[0].depth, 0); + assert_eq!(services.beta_cutoffs, 0, "no search_value => no cutoffs"); + assert_eq!( + services.killer_orderings, 0, + "no search_value => no killer ordering" + ); + assert!( + services + .killers + .iter() + .all(|ply| ply.iter().all(Option::is_none)), + "no cutoffs => killer table stays empty" + ); + } + + // V6: the rung witness records completion + node usage for every executed rung. + #[test] + fn rung_stats_record_completion_and_node_usage() { + let state = searchable_state(); + let policies = PolicyRegistry::shared(); + let config = create_config(AiDifficulty::Hard, Platform::Native).into_measurement(7); + let mut services = PlannerServices::new_default(PlayerId(0), &config, policies); + let ranked = build_root_beam(&state, &services); + let _ = run_iterative_deepening(&state, ranked, 0.1, &config, &mut services); + + let ceiling = config.search.max_depth.saturating_sub(1); + assert!( + ceiling >= 1, + "fixture precondition: ceiling deepens past rung 0" + ); + assert_eq!( + services.rung_stats.len() as u32, + ceiling + 1, + "one witness per rung 0..=ceiling" + ); + assert!( + services.rung_stats.iter().all(|r| r.completed), + "roomy measurement budget: every rung completes" + ); + for r in services.rung_stats.iter().filter(|r| r.depth >= 1) { + assert!( + r.nodes_used > 0, + "searched rungs (depth >= 1) consume nodes" + ); + } + } + + // V6 hostile (saturation): a tiny node cap saturates the deepest searched rung + // while it is still ACCEPTED (node-budget exhaustion does not discard). The + // saturation predicate is `nodes_used >= max_nodes` (not `==`): `tick()` + // increments unconditionally at `search_value` entry while `exhausted()` checks + // `>=`, so the counter can overshoot the cap by one. + #[test] + fn rung_stats_saturated_rung_is_still_accepted() { + let state = searchable_state(); + let policies = PolicyRegistry::shared(); + let mut config = create_config(AiDifficulty::Hard, Platform::Native).into_measurement(7); + config.search.max_nodes = 4; // tiny -> deepest searched rung saturates + let mut services = PlannerServices::new_default(PlayerId(0), &config, policies); + let ranked = build_root_beam(&state, &services); + let _ = run_iterative_deepening(&state, ranked, 0.1, &config, &mut services); + + let deepest = services.rung_stats.last().expect("at least rung 0 ran"); + assert!( + deepest.completed, + "node-budget exhaustion must NOT discard a rung" + ); + assert!( + services + .rung_stats + .iter() + .any(|r| r.depth >= 1 && r.nodes_used >= r.max_nodes), + "a searched rung must saturate the tiny node pool (nodes_used >= max_nodes)" + ); + } + + // V6 hostile (pre-expired): an already-expired interactive deadline breaks at + // the rung-entry guard before any candidate loop runs, so zero rungs execute + // and the witness list is empty — the honest "no search happened" trace (and + // the floor is still returned). + #[test] + fn pre_expired_deadline_records_no_rungs() { + let state = searchable_state(); + let policies = PolicyRegistry::shared(); + let config = create_config(AiDifficulty::Hard, Platform::Native); // interactive + let context = crate::context::AiContext::empty(&config.weights); + let mut services = PlannerServices::with_deadline( + PlayerId(0), + &config, + policies, + context, + Some(engine::util::Deadline::after(0)), // pre-expired + ); + let ranked = build_root_beam(&state, &services); + assert!(!ranked.is_empty(), "reach-guard: the beam is non-empty"); + let out = run_iterative_deepening(&state, ranked, 0.1, &config, &mut services); + assert!(!out.is_empty(), "the tactical-only floor is still returned"); + assert!( + services.rung_stats.is_empty(), + "a pre-expired deadline executes zero rungs => no rung witness" + ); + } + + // V3 tie row: `pv_argmax` resolves ties and non-finite scores through the + // `cmp_stable` total order — deterministic across calls and panic-free on NaN + // (never a bare `max_by(|a, b| a.partial_cmp(b).unwrap())`). + #[test] + fn pv_argmax_is_deterministic_and_nan_safe() { + let tied = vec![ + (GameAction::PassPriority, 5.0), + (GameAction::CancelCast, 5.0), + ]; + let pick = pv_argmax(&tied).cloned(); + assert_eq!( + pv_argmax(&tied).cloned(), + pick, + "tie resolution is byte-stable across repeated calls" + ); + assert!( + pick == Some(GameAction::PassPriority) || pick == Some(GameAction::CancelCast), + "the winner is one of the tied actions" + ); + // A NaN score must resolve via the Equal fallback, never panic. + let with_nan = vec![ + (GameAction::PassPriority, f64::NAN), + (GameAction::CancelCast, 1.0), + ]; + let _ = pv_argmax(&with_nan); + assert!(pv_argmax(&[]).is_none(), "empty input yields None"); + } + + // V3: the rung-1 PV rotate steers the shared per-rung budget to the PV + // candidate. Budget-starvation fixture: a tight node cap means the first- + // searched root subtree drains the pool. With the rotate, the PV candidate B + // is searched FIRST at rung 2, so its rung-2 score equals its independent + // full-depth continuation (computed on FRESH services). Reverting the rotate + // makes A drain the pool first and B collapse toward quiesced eval. + #[test] + fn pv_rotate_gives_pv_candidate_full_depth_under_starvation() { + let state = starvation_state(); + let policies = PolicyRegistry::shared(); + let mut config = create_config(AiDifficulty::Hard, Platform::Native).into_measurement(7); + config.search.max_depth = 3; // ceiling 2 (rung 1 sets PV, rung 2 uses it) + config.search.max_nodes = 6; // tight: one root subtree drains the pool + let tw = 0.1; + + // Beam deliberately ordered PASS-FIRST so ranked[0] = A = pass while the + // board-improving cast (B) is the search argmax — the case where the PV + // rotate matters. Scores are 0.0 so the value function is pure continuation + // (no tactical term interfering with the demonstration). + let (pass, cast) = pass_and_first_cast(&state); + let ranked = vec![ + RankedCandidate { + candidate: pass.clone(), + score: 0.0, + }, + RankedCandidate { + candidate: cast.clone(), + score: 0.0, + }, + ]; + let a = ranked[0].candidate.action.clone(); + + // The PV rung 2 searches first == rung-1's argmax under this beam/budget. + let b = { + let mut cfg1 = config.clone(); + cfg1.search.max_depth = 2; // ceiling 1 + let mut s = PlannerServices::new_default(PlayerId(0), &cfg1, policies); + let rung1 = run_iterative_deepening(&state, ranked.clone(), tw, &cfg1, &mut s); + pv_argmax(&rung1).cloned().expect("rung 1 has an argmax") + }; + assert_ne!(b, a, "reach-guard: the PV must differ from ranked[0]"); + + let b_ranked = ranked + .iter() + .find(|r| r.candidate.action == b) + .expect("B is in the beam"); + let b_tactical = b_ranked.score; + let b_sim = apply_candidate(&state, &b_ranked.candidate).expect("B applies"); + + // Independent full-depth control on FRESH services (empty TT) + fresh + // budget. `eval_cache` is a pure-function memo (value-transparent), so only + // the TT could contaminate the comparison — guarded below by tt_hits == 0. + let control_cont = { + let mut fresh = PlannerServices::new_default(PlayerId(0), &config, policies); + let mut fresh_budget = SearchBudget::new(config.search.max_nodes); + let planner = BeamContinuationPlanner { + depth: 2, + rollout_depth: config.search.rollout_depth, + }; + planner.search_value( + &b_sim, + 2, + 0, + f64::NEG_INFINITY, + f64::INFINITY, + &mut fresh, + &mut fresh_budget, + ) + }; + let control_quiesced = { + let mut q = PlannerServices::new_default(PlayerId(0), &config, policies); + q.evaluate_state_quiesced(&b_sim) + }; + // Precondition (b): B's searched value differs from its quiesced eval, else + // reverting the rotate could not fail the score assertion. + assert_ne!( + control_cont, control_quiesced, + "B's depth-2 searched value must differ from its quiesced eval" + ); + + // Measured run: ceiling 2, pass-first beam. Rung 1 sets PV = B; rung 2 + // rotates B to the front and searches it first with the fresh per-rung pool. + let mut services = PlannerServices::new_default(PlayerId(0), &config, policies); + let out = run_iterative_deepening(&state, ranked, tw, &config, &mut services); + + // TT-contamination reach-guard: the measured/control equality is TT-free. + assert_eq!( + services.tt_hits, 0, + "no transposition hits => control equality is TT-provenance-free" + ); + // Starvation regime reach-guard: a searched rung saturated the pool. + assert!( + services + .rung_stats + .iter() + .any(|r| r.depth >= 1 && r.nodes_used >= r.max_nodes), + "a searched rung saturated the node pool (the starvation regime)" + ); + + let out_b = score_of(&out, &b); + assert!( + (out_b - (control_cont + b_tactical * tw)).abs() < 1e-9, + "PV-first gives B its full-depth continuation value \ + (got {out_b}, expected {})", + control_cont + b_tactical * tw + ); + } + + // V4: the rung-0 rotate is skipped (the `iter_depth >= 1` gate), so rung 1 + // provably sees today's ordering. Two ceiling-1 runs on fresh services: one on + // the natural beam, one on a beam pre-rotated to put rung-0's argmax first. + // With the gate present, run 1's rung-0 does NOT rotate, so its rung-1 order + // differs from the pre-rotated run under starvation => outputs differ. Removing + // the gate makes run 1 also rotate rung-0's argmax to the front, collapsing the + // two outputs to equality — so `assert_ne!` is revert-failing for the gate. + #[test] + fn rung_zero_rotate_is_gated_off() { + let state = starvation_state(); + let policies = PolicyRegistry::shared(); + let mut config = create_config(AiDifficulty::Hard, Platform::Native).into_measurement(7); + config.search.max_depth = 2; // ceiling 1 + // Depth-1 rung subtrees are shallow, so the cap must be very tight to + // starve at rung 1 (make its output order-sensitive). 3 nodes lets the + // first candidate search while the second collapses to quiesced eval. + config.search.max_nodes = 3; + let tw = 0.1; + + // Pass-first beam so rung-0's argmax (the board-improving cast) differs + // from ranked[0] = pass — making a rung-0 rotate observable. + let (pass, cast) = pass_and_first_cast(&state); + let ranked = vec![ + RankedCandidate { + candidate: pass.clone(), + score: 0.0, + }, + RankedCandidate { + candidate: cast.clone(), + score: 0.0, + }, + ]; + let a = ranked[0].candidate.action.clone(); + + // rung-0 argmax (quiesced eval per candidate) via a ceiling-0 run. + let b0 = { + let mut cfg0 = config.clone(); + cfg0.search.planner_mode = PlannerMode::BeamOnly; // ceiling 0 + let mut s = PlannerServices::new_default(PlayerId(0), &cfg0, policies); + let rung0 = run_iterative_deepening(&state, ranked.clone(), tw, &cfg0, &mut s); + pv_argmax(&rung0).cloned().expect("rung 0 has an argmax") + }; + // Reach-guard: rung-0 argmax must differ from ranked[0], else pre-rotating + // is a no-op and the test is vacuous. + assert_ne!( + b0, a, + "reach-guard: rung-0 argmax differs from ranked[0] (rotate is observable)" + ); + + // Run 1: natural beam (with the gate, rung 1 keeps this order). + let out_natural = { + let mut s = PlannerServices::new_default(PlayerId(0), &config, policies); + run_iterative_deepening(&state, ranked.clone(), tw, &config, &mut s) + }; + // Run 2: beam pre-rotated so B0 is first (mimics an un-gated rung-0 rotate). + let out_prerotated = { + let mut pre = ranked.clone(); + rotate_pv_to_front(&mut pre, &b0); + let mut s = PlannerServices::new_default(PlayerId(0), &config, policies); + run_iterative_deepening(&state, pre, tw, &config, &mut s) + }; + + assert_ne!( + out_natural, out_prerotated, + "with the rung-0 gate, rung 1 keeps today's order; the pre-rotated \ + (un-gated) order diverges under starvation. Removing the gate makes \ + these equal." + ); + } + + // V7b: ensemble determinism on the public surface. K >= 2 measurement runs must + // be byte-identical — the new killer/rung state is arrays with no HashMap + // iteration order, so #4878-style ordering stability holds end-to-end. + #[test] + fn ensemble_is_deterministic_with_move_ordering() { + let state = searchable_state(); + let mut config = create_config(AiDifficulty::Hard, Platform::Native).into_measurement(7); + config.search.determinization_samples = 2; + let session = AiSession::arc_from_game(&state); + + let first = score_candidates_with_session(&state, PlayerId(0), &config, &session); + let second = score_candidates_with_session(&state, PlayerId(0), &config, &session); + + assert!( + has_cast(&first), + "reach-guard: the search-enabled ID loop is reached" + ); + assert_eq!( + first, second, + "K >= 2 ensemble output must be byte-identical across runs" + ); + } } diff --git a/crates/phase-ai/tests/duel_suite_attribution.rs b/crates/phase-ai/tests/duel_suite_attribution.rs index e74afc0e3e..43397510e4 100644 --- a/crates/phase-ai/tests/duel_suite_attribution.rs +++ b/crates/phase-ai/tests/duel_suite_attribution.rs @@ -110,6 +110,7 @@ fn declared_exercises_appear_in_attribution() { attribution: AttributionMode::Enabled, git_sha: None, card_data_hash: None, + harvest_output: None, }; let report = run_suite(&db, &opts).expect("suite run"); diff --git a/crates/phase-ai/tests/duel_suite_harvest.rs b/crates/phase-ai/tests/duel_suite_harvest.rs new file mode 100644 index 0000000000..6e5180e830 --- /dev/null +++ b/crates/phase-ai/tests/duel_suite_harvest.rs @@ -0,0 +1,139 @@ +//! End-to-end guards for the U4 self-play eval-feature harvest (`--harvest`). +//! +//! These drive real duel-suite games, so they load `card-data.json` and are +//! `#[ignore]`d (CI has no card data). Opt in with: +//! +//! ```text +//! cargo test -p phase-ai --test duel_suite_harvest -- --ignored +//! ``` +//! +//! They cover the verification-matrix rows that need a real game loop: +//! - **Harvest determinism + sink lifecycle** — two identical runs produce +//! byte-identical JSONL with exactly ONE meta line (line 1) and records from +//! multiple games sharing the one file. +//! - **Labeling correct + both classes present** — the near-50% `red-mirror` +//! matchup at base seed `0xA157A1` yields both win and loss labels by K=12. +//! +//! The `harvest.rs` inline unit tests cover the `GameHarvester` gating / flush / +//! finish semantics at the building-block level (no card data required). + +use std::path::{Path, PathBuf}; + +use engine::database::CardDatabase; +use phase_ai::config::AiDifficulty; +use phase_ai::duel_suite::run::{run_suite, SuiteOptions}; + +const RED_MIRROR: &str = "red-mirror"; +const MIRROR_BASE_SEED: u64 = 0x00A1_57A1; + +fn load_db() -> CardDatabase { + let cards_dir = std::env::var("PHASE_CARDS_PATH") + .map(PathBuf::from) + .unwrap_or_else(|_| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("client") + .join("public") + }); + let export_path = cards_dir.join("card-data.json"); + CardDatabase::from_export(&export_path) + .unwrap_or_else(|e| panic!("load card-data.json from {}: {e}", export_path.display())) +} + +/// A line is a meta line iff its JSON carries a top-level `meta` key — key +/// presence, not substring, so a record field containing the text `"meta"` +/// can never miscount (mirrors `train_eval_weights.py`'s loader). +fn is_meta_line(line: &str) -> bool { + serde_json::from_str::(line).is_ok_and(|v| v.get("meta").is_some()) +} + +fn harvest_opts(games: usize, base_seed: u64, harvest_path: &Path) -> SuiteOptions { + let mut opts = SuiteOptions::new(AiDifficulty::Medium, games, base_seed); + opts.output_path = PathBuf::from("target/duel-suite-harvest-report.json"); + opts.filter = Some(RED_MIRROR.to_string()); + opts.harvest_output = Some(harvest_path.to_path_buf()); + opts +} + +/// One meta line (line 1), records from every game appended to the SAME file, +/// and byte-identical output across two runs of the same `(matchup, seed, K)`. +#[test] +#[ignore = "loads card-data.json + runs real games; opt in via --ignored"] +fn harvest_is_deterministic_with_single_meta_line() { + let db = load_db(); + let games = 3; + + let path_a = PathBuf::from("target/test-artifacts/u4-harvest-det/run-a.jsonl"); + let path_b = PathBuf::from("target/test-artifacts/u4-harvest-det/run-b.jsonl"); + run_suite(&db, &harvest_opts(games, MIRROR_BASE_SEED, &path_a)).expect("run a"); + run_suite(&db, &harvest_opts(games, MIRROR_BASE_SEED, &path_b)).expect("run b"); + + let a = std::fs::read_to_string(&path_a).expect("read a"); + let b = std::fs::read_to_string(&path_b).expect("read b"); + assert_eq!( + a, b, + "same (matchup, seed, K) must produce byte-identical JSONL" + ); + + let lines: Vec<&str> = a.lines().filter(|l| !l.trim().is_empty()).collect(); + let meta_lines = lines.iter().filter(|l| is_meta_line(l)).count(); + assert_eq!(meta_lines, 1, "exactly one file-scoped meta line"); + assert!( + is_meta_line(lines[0]), + "the meta line must be line 1, got: {}", + lines[0] + ); + + // Records from more than one game share the single file. + let seeds: std::collections::HashSet = lines + .iter() + .filter(|l| !is_meta_line(l)) + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter_map(|v| v.get("seed").and_then(|s| s.as_u64())) + .collect(); + assert!( + seeds.len() >= 2, + "records from multiple games must share one file (distinct seeds: {})", + seeds.len() + ); +} + +/// The near-50% red mirror yields BOTH win and loss labels by K=12 (grown +/// through {4, 8, 12}). Deterministic — once green, green forever; a red here +/// means an AI change shifted the mirror, so bump `MIRROR_BASE_SEED`. +#[test] +#[ignore = "loads card-data.json + runs real games; opt in via --ignored"] +fn harvest_labels_both_classes_on_red_mirror() { + let db = load_db(); + let path = PathBuf::from("target/test-artifacts/u4-harvest-labels/run.jsonl"); + + let mut satisfied_at = None; + for &games in &[4usize, 8, 12] { + run_suite(&db, &harvest_opts(games, MIRROR_BASE_SEED, &path)).expect("harvest run"); + let text = std::fs::read_to_string(&path).expect("read jsonl"); + let mut saw_win = false; + let mut saw_loss = false; + for line in text + .lines() + .filter(|l| !l.trim().is_empty() && !is_meta_line(l)) + { + let v: serde_json::Value = serde_json::from_str(line).expect("record json"); + match v.get("won").and_then(|w| w.as_bool()) { + Some(true) => saw_win = true, + Some(false) => saw_loss = true, + None => panic!("record missing bool `won`: {line}"), + } + } + if saw_win && saw_loss { + satisfied_at = Some(games); + break; + } + } + + assert!( + satisfied_at.is_some(), + "red-mirror produced a single label class through K=12 — an AI change \ + likely shifted the mirror; bump MIRROR_BASE_SEED" + ); +} diff --git a/scripts/train_eval_weights.py b/scripts/train_eval_weights.py index d3b30b38a6..9362f28b48 100644 --- a/scripts/train_eval_weights.py +++ b/scripts/train_eval_weights.py @@ -58,6 +58,27 @@ # Target maximum absolute weight value after scaling. MAX_WEIGHT_SCALE = 2.5 +# Self-play harvest features. Names are exactly the EvalWeights struct fields, so +# each fitted coefficient maps DIRECTLY onto its weight (no proxy map). All 9 are +# fitted; `energy_offset` is a fixed-coefficient control fed to the regression and +# then discarded (matching the engine's post-weighting energy offset). +SELFPLAY_FEATURE_NAMES = [ + "life", + "board_presence", + "board_power", + "board_toughness", + "hand_size", + "aggression", + "card_advantage", + "zone_quality", + "synergy", +] +SELFPLAY_CONTROL = "energy_offset" + +# Smoke thresholds under --allow-tiny-corpus (vs the production 1000 / 100). +TINY_GLOBAL_MIN = 10 +TINY_PHASE_MIN = 2 + # Turn boundaries for game phases. EARLY_MAX = 3 # turns 1-3 MID_MAX = 7 # turns 4-7 @@ -430,6 +451,246 @@ def extract_and_scale_weights( return raw_coefs, weights +def load_selfplay_corpus( + glob_pattern: str, +) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], dict, list[str], set]: + """Read JSONL harvest shards, skip meta lines, bucket rows by turn phase. + + Returns (phase_features, phase_labels, meta, files, seeds) where + phase_features values are (N, 10) arrays (9 fitted features + the + `energy_offset` control) and phase_labels values are (N,) arrays of 0/1. + """ + files = sorted(glob.glob(glob_pattern)) + if not files: + print( + f"ERROR: No self-play JSONL shards matched {glob_pattern}", + file=sys.stderr, + ) + sys.exit(1) + + phase_feat: dict[str, list[list[float]]] = {"early": [], "mid": [], "late": []} + phase_lab: dict[str, list[int]] = {"early": [], "mid": [], "late": []} + meta: dict = {} + seeds: set = set() + total = 0 + columns = SELFPLAY_FEATURE_NAMES + [SELFPLAY_CONTROL] + + for path in files: + with open(path) as handle: + for line in handle: + line = line.strip() + if not line: + continue + obj = json.loads(line) + if "meta" in obj: + # First meta line wins for provenance; shards share a run. + if not meta: + meta = obj["meta"] + continue + feats = obj["features"] + phase = turn_phase(int(obj["turn"])) + phase_feat[phase].append([float(feats[name]) for name in columns]) + phase_lab[phase].append(1 if obj["won"] else 0) + seeds.add(int(obj["seed"])) + total += 1 + + phase_features = {} + phase_labels = {} + for phase in ["early", "mid", "late"]: + if phase_feat[phase]: + phase_features[phase] = np.asarray(phase_feat[phase], dtype=np.float64) + phase_labels[phase] = np.asarray(phase_lab[phase], dtype=np.int64) + else: + phase_features[phase] = np.empty((0, len(columns))) + phase_labels[phase] = np.empty(0) + print( + f" {phase}: {len(phase_features[phase])} samples", + file=sys.stderr, + ) + + print(f"\nTotal self-play samples: {total}", file=sys.stderr) + return phase_features, phase_labels, meta, files, seeds + + +def train_selfplay_model( + X: np.ndarray, y: np.ndarray, phase_name: str +): + """Train logistic regression for one self-play phase. + + Skips single-class phases with a warning instead of crashing inside + `train_test_split(stratify=y)` (which requires ≥2 classes). Returns + `(model, train_acc, test_acc)` or `None` when skipped. + """ + finite_mask = np.isfinite(X).all(axis=1) + if not finite_mask.all(): + n_bad = int((~finite_mask).sum()) + print(f" {phase_name}: dropping {n_bad} rows with inf/NaN values", file=sys.stderr) + X = X[finite_mask] + y = y[finite_mask] + + # train_test_split(stratify=y) requires at least two classes AND at least + # two members per class; a single-class phase (all wins or all losses) or a + # class with one member (reachable under --allow-tiny-corpus, whose + # per-phase floor is 2) is unsplittable — skip it rather than crash. + classes, class_counts = np.unique(y, return_counts=True) + if len(classes) < 2 or class_counts.min() < 2: + print( + f" WARNING: {phase_name} lacks two outcome classes with >=2 " + "members each — skipping (cannot stratify).", + file=sys.stderr, + ) + return None + + X_train, X_test, y_train, y_test = train_test_split( + X, y, test_size=0.2, random_state=42, stratify=y + ) + model = LogisticRegression(penalty="l2", C=1.0, max_iter=1000, random_state=42) + model.fit(X_train, y_train) + train_accuracy = model.score(X_train, y_train) + test_accuracy = model.score(X_test, y_test) + print( + f"\n {phase_name} accuracy: train={train_accuracy:.4f} test={test_accuracy:.4f}", + file=sys.stderr, + ) + return model, train_accuracy, test_accuracy + + +def extract_selfplay_weights(model: LogisticRegression, phase_name: str) -> tuple[dict, dict]: + """Map self-play coefficients DIRECTLY onto weight names (no proxy map). + + The `energy_offset` control coefficient is recorded but discarded — it is a + fixed serve-time offset, not a fitted weight. Remaining coefficients are + `abs()`-ed and scaled so the max maps to MAX_WEIGHT_SCALE. HAND_TUNED is NOT + applied: self-play measures every weight. + """ + columns = SELFPLAY_FEATURE_NAMES + [SELFPLAY_CONTROL] + raw_coefs = {name: round(float(coef), 6) for name, coef in zip(columns, model.coef_[0])} + + print(f" {phase_name} raw coefficients:", file=sys.stderr) + for name, coef in raw_coefs.items(): + sign = "+" if coef >= 0 else "" + tag = " (control, discarded)" if name == SELFPLAY_CONTROL else "" + print(f" {name}: {sign}{coef}{tag}", file=sys.stderr) + + fitted = {name: raw_coefs[name] for name in SELFPLAY_FEATURE_NAMES} + max_abs = max(abs(v) for v in fitted.values()) if fitted else 1.0 + scale_factor = MAX_WEIGHT_SCALE / max_abs if max_abs > 0 else 1.0 + weights = {name: round(abs(coef) * scale_factor, 4) for name, coef in fitted.items()} + + print(f" {phase_name} scaled weights (all self-play fitted):", file=sys.stderr) + for name, val in weights.items(): + print(f" {name}: {val}", file=sys.stderr) + + return raw_coefs, weights + + +def run_selfplay(args) -> None: + """Fit all 9 EvalWeights per phase from a self-play harvest corpus.""" + print("=== Self-Play Phase-Aware EvalWeights Training ===\n", file=sys.stderr) + + phase_features, phase_labels, meta, files, seeds = load_selfplay_corpus(args.selfplay_glob) + + total_samples = sum(len(v) for v in phase_features.values()) + global_min = TINY_GLOBAL_MIN if args.allow_tiny_corpus else 1000 + if total_samples < global_min: + print( + f"ERROR: Only {total_samples} self-play samples " + f"(need >= {global_min}; pass --allow-tiny-corpus for smoke runs).", + file=sys.stderr, + ) + sys.exit(1) + + phase_min = TINY_PHASE_MIN if args.allow_tiny_corpus else 100 + print("\n--- Training phase-specific self-play models ---", file=sys.stderr) + phase_results = {} + for phase in ["early", "mid", "late"]: + X = phase_features[phase].astype(np.float64) + y = phase_labels[phase].astype(np.int64) + if len(X) < phase_min: + print( + f" WARNING: {phase} has only {len(X)} samples " + f"(< {phase_min}), skipping", + file=sys.stderr, + ) + continue + trained = train_selfplay_model(X, y, phase) + if trained is None: + continue + model, train_acc, test_acc = trained + raw_coefs, weights = extract_selfplay_weights(model, phase) + phase_results[phase] = { + "sample_count": int(len(X)), + "train_accuracy": round(train_acc, 4), + "test_accuracy": round(test_acc, 4), + "raw_coefficients": raw_coefs, + "weights": weights, + } + + if not phase_results: + # Every phase was empty, sub-threshold, or single-class — no fittable + # signal. A fully single-class corpus lands here. + print( + "ERROR: no phase produced fittable weights (all phases were empty, " + "below threshold, or single-class). Nothing to write.", + file=sys.stderr, + ) + sys.exit(1) + + # Identity/provenance: the artifact is the identity-bearing object. + output = { + "kind": "selfplay_phase_weights", + "source": "phase_ai_selfplay_harvest", + "git_sha": meta.get("git_sha"), + "card_data_hash": meta.get("card_data_hash"), + "difficulty": meta.get("difficulty"), + "corpus_files": [os.path.basename(f) for f in files], + "base_seeds": sorted(seeds), + "total_sample_count": total_samples, + "feature_names": SELFPLAY_FEATURE_NAMES, + "control_feature": SELFPLAY_CONTROL, + "turn_boundaries": { + "early": f"turns 1-{EARLY_MAX}", + "mid": f"turns {EARLY_MAX + 1}-{MID_MAX}", + "late": f"turns {MID_MAX + 1}+", + }, + "phases": phase_results, + } + + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + with open(args.output, "w") as f: + json.dump(output, f, indent=2) + f.write("\n") + + print(f"\nSelf-play weights written to {args.output}", file=sys.stderr) + + # Comparison table: self-play fit vs the shipped 17Lands baseline (read from + # data/learned-weights.json when present). LR is convex, so 17Lands weights are + # the comparison baseline / shipped fallback, not a numerical initialization. + baseline = {} + baseline_path = os.path.join("data", "learned-weights.json") + if os.path.exists(baseline_path): + try: + with open(baseline_path) as bf: + baseline = json.load(bf).get("phases", {}) + except (OSError, json.JSONDecodeError): + baseline = {} + + print("\n=== Self-play vs 17Lands weight comparison ===", file=sys.stderr) + for phase in ["early", "mid", "late"]: + sp = phase_results.get(phase, {}).get("weights") + if not sp: + continue + bl = baseline.get(phase, {}).get("weights", {}) + print(f"\n[{phase}] {'weight':<16}{'selfplay':>10}{'17lands':>10}", file=sys.stderr) + for name in SELFPLAY_FEATURE_NAMES: + sp_val = sp.get(name) + bl_val = bl.get(name, "-") + bl_str = f"{bl_val:>10.4f}" if isinstance(bl_val, (int, float)) else f"{bl_val:>10}" + print(f" {'':<2}{name:<16}{sp_val:>10.4f}{bl_str}", file=sys.stderr) + + print("\nDone.", file=sys.stderr) + + def main(): parser = argparse.ArgumentParser( description="Train turn-phase-aware AI evaluation weights from 17Lands replay data." @@ -456,8 +717,29 @@ def main(): default=50, help="Minimum user_n_games_bucket filter (default: 50)", ) + parser.add_argument( + "--source", + choices=["17lands", "selfplay"], + default="17lands", + help="Training data source (default: 17lands; the existing path is untouched)", + ) + parser.add_argument( + "--selfplay-glob", + default="data/selfplay/*.jsonl", + help="Glob for self-play harvest JSONL shards (--source selfplay)", + ) + parser.add_argument( + "--allow-tiny-corpus", + action="store_true", + help="Lower sample-count guards to smoke thresholds " + f"({TINY_GLOBAL_MIN} global / {TINY_PHASE_MIN} per phase) for pipeline validation", + ) args = parser.parse_args() + if args.source == "selfplay": + run_selfplay(args) + return + print("=== 17Lands Phase-Aware EvalWeights Training ===\n", file=sys.stderr) # Load card metadata