Skip to main content

selfplay_export/
selfplay_export.rs

1//! Self-play data exporter: plays full games with MCTSEngine on both
2//! sides, and writes one JSONL training row per ply — position, the
3//! visit-count policy target (Task 5's `root_move_visits`), and the
4//! eventual game outcome from that ply's mover perspective. Quantik has
5//! no draws (see docs/benchmarks/quantik-game-tree-census-2026-07-13.md),
6//! so `value` is always exactly +1.0 or -1.0, never 0.0.
7//!
8//! Row schema (one per line, compact canonical JSON):
9//! {
10//!   "game_id": u64,
11//!   "ply": u32,
12//!   "qfen": string,
13//!   "side_to_move": 0 | 1,
14//!   "policy": [{"shape": u8, "position": u8, "visits": u32}, ...],
15//!   "value": 1.0 | -1.0   // outcome for `side_to_move`, decided in hindsight
16//! }
17//!
18//! Usage:
19//!   cargo run --release --example selfplay_export -- \
20//!     --games 100 --iterations 2000 --seed 20260713 \
21//!     --out benchmarks/results/selfplay.jsonl
22
23use quantik_core::bench::canonical::canonical_json;
24use quantik_core::bitboard::Bitboard;
25use quantik_core::game::{check_winner, current_player, has_winning_line, WinStatus};
26use quantik_core::mcts::{MCTSConfig, MCTSEngine};
27use quantik_core::moves::{apply_move, generate_legal_moves};
28use quantik_core::state::State;
29use serde_json::json;
30use std::io::Write;
31
32struct Args {
33    games: u32,
34    iterations: u32,
35    seed: u64,
36    out: String,
37}
38
39fn parse_args() -> Args {
40    let mut games = 100u32;
41    let mut iterations = 2000u32;
42    let mut seed = 20260713u64;
43    let mut out = "benchmarks/results/selfplay.jsonl".to_string();
44    let mut it = std::env::args().skip(1);
45    while let Some(flag) = it.next() {
46        match flag.as_str() {
47            "--games" => games = it.next().unwrap().parse().unwrap(),
48            "--iterations" => iterations = it.next().unwrap().parse().unwrap(),
49            "--seed" => seed = it.next().unwrap().parse().unwrap(),
50            "--out" => out = it.next().unwrap(),
51            other => panic!("unknown flag {other}"),
52        }
53    }
54    Args {
55        games,
56        iterations,
57        seed,
58        out,
59    }
60}
61
62struct PendingRow {
63    ply: u32,
64    qfen: String,
65    side_to_move: u8,
66    policy: Vec<(u8, u8, u32)>, // (shape, position, visits)
67}
68
69/// Play one self-play game to completion, returning one pending row per
70/// ply (value filled in afterward, once the winner is known).
71fn play_game(seed: u64, iterations: u32) -> (Vec<PendingRow>, WinStatus) {
72    let mut bb = Bitboard::EMPTY;
73    let mut rows = Vec::new();
74    let mut ply = 0u32;
75
76    loop {
77        if has_winning_line(&bb) {
78            return (rows, check_winner(&bb));
79        }
80        let legal = generate_legal_moves(&bb);
81        if legal.is_empty() {
82            // No legal moves: the side to move loses (see Global
83            // Constraints — this is a decisive result, never a draw).
84            let loser = current_player(&bb).unwrap();
85            let winner = if loser == 0 {
86                WinStatus::Player1Wins
87            } else {
88                WinStatus::Player0Wins
89            };
90            return (rows, winner);
91        }
92
93        let side_to_move = current_player(&bb).unwrap();
94        // use_transposition_table MUST be false here: with it on (the
95        // engine's actual default), root moves that canonicalize to the
96        // same child are merged onto one shared node and reported under a
97        // single arbitrary move, silently dropping every other legal move
98        // that led there — worst exactly at shallow plies (the empty
99        // board's 64 legal moves collapse to 3), which every self-play
100        // game passes through. Verified in mcts.rs's
101        // root_move_visits_default_config_collapses_symmetric_root_moves
102        // test — this is not a hypothetical concern, it was caught by
103        // Opus review of the PR that added root_move_visits and is
104        // exactly what this exporter must avoid to produce a faithful
105        // per-legal-move policy target.
106        let mut engine = MCTSEngine::new(MCTSConfig {
107            max_iterations: iterations,
108            seed: Some(seed.wrapping_add(ply as u64)),
109            use_transposition_table: false,
110            ..Default::default()
111        });
112        let (best_move, _) = engine.search(&bb).expect("legal moves exist");
113        let policy: Vec<(u8, u8, u32)> = engine
114            .root_move_visits()
115            .into_iter()
116            .map(|(mv, visits)| (mv.shape, mv.position, visits))
117            .collect();
118
119        rows.push(PendingRow {
120            ply,
121            qfen: State::new(bb).to_qfen(),
122            side_to_move,
123            policy,
124        });
125
126        bb = apply_move(&bb, &best_move);
127        ply += 1;
128    }
129}
130
131fn main() {
132    let args = parse_args();
133    if let Some(parent) = std::path::Path::new(&args.out).parent() {
134        std::fs::create_dir_all(parent).expect("mkdir output dir");
135    }
136    let mut file = std::fs::File::create(&args.out).expect("create output file");
137
138    for game_id in 0..args.games {
139        let (rows, winner) = play_game(
140            args.seed.wrapping_add(game_id as u64 * 1000),
141            args.iterations,
142        );
143        assert_ne!(
144            winner,
145            WinStatus::NoWin,
146            "game must resolve to a decisive winner"
147        );
148
149        for row in rows {
150            let value = match (winner, row.side_to_move) {
151                (WinStatus::Player0Wins, 0) => 1.0,
152                (WinStatus::Player0Wins, 1) => -1.0,
153                (WinStatus::Player1Wins, 0) => -1.0,
154                (WinStatus::Player1Wins, 1) => 1.0,
155                // current_player() (the only source of side_to_move) never
156                // returns anything but 0 or 1; WinStatus::NoWin is asserted
157                // impossible above. u8's full range still requires an
158                // exhaustive catch-all for the match to compile.
159                _ => unreachable!("side_to_move is always 0 or 1, winner is always decisive"),
160            };
161            let policy_json: Vec<_> = row
162                .policy
163                .iter()
164                .map(|(shape, position, visits)| {
165                    json!({"shape": shape, "position": position, "visits": visits})
166                })
167                .collect();
168            let record = json!({
169                "game_id": game_id,
170                "ply": row.ply,
171                "qfen": row.qfen,
172                "side_to_move": row.side_to_move,
173                "policy": policy_json,
174                "value": value,
175            });
176            writeln!(file, "{}", canonical_json(&record)).expect("write row");
177        }
178
179        if (game_id + 1) % 10 == 0 || game_id + 1 == args.games {
180            println!(
181                "{}/{} games exported -> {}",
182                game_id + 1,
183                args.games,
184                args.out
185            );
186        }
187    }
188}