Skip to main content

quantik_core/
evaluation.rs

1//! Fitted-linear handcrafted evaluation for non-terminal Quantik positions.
2//!
3//! Scores a position as the dot product of a small, hand-designed feature
4//! vector and a fitted weight vector (`EvalConfig::weights`). `features` is
5//! a pure function of `(bb, player)`. Port of the Python
6//! `quantik_core.evaluation` module; feature semantics must stay identical
7//! so fitted weights remain interchangeable between implementations.
8
9use crate::bitboard::Bitboard;
10use crate::constants::WIN_MASKS;
11use crate::game::current_player;
12use crate::moves::generate_legal_moves;
13
14/// Feature vector layout produced by [`features`], in order.
15pub const FEATURE_NAMES: [&str; 6] = [
16    "threat_own",
17    "threat_opp",
18    "threat_shared",
19    "mobility_diff",
20    "build_two",
21    "build_one",
22];
23
24const SEEDED_WEIGHTS: [f64; 6] = [100.0, -100.0, 20.0, 3.0, 2.0, 0.0];
25
26/// Weights for the fitted-linear evaluation and the terminal win bonus.
27///
28/// `weights` follows [`FEATURE_NAMES`] order. `win` is not consumed by
29/// [`evaluate`] (which only scores non-terminal positions) — it is used by
30/// search engines built on top of this module to score forced wins.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct EvalConfig {
33    pub weights: [f64; 6],
34    pub win: f64,
35}
36
37impl Default for EvalConfig {
38    fn default() -> Self {
39        Self {
40            weights: SEEDED_WEIGHTS,
41            win: 10_000.0,
42        }
43    }
44}
45
46/// Whether `player` may place `shape` at the empty `position`.
47///
48/// Mirrors the win-line rule (a shape cannot be placed on a line where the
49/// opponent already holds the same shape) without the occupancy check,
50/// since callers only ask this for cells already known to be empty.
51fn placement_is_legal(bb: &Bitboard, player: u8, shape: u8, position: u8) -> bool {
52    let opponent_shape_bits = bb.planes[Bitboard::plane_index(1 - player, shape)];
53    let position_mask = 1u16 << position;
54    for &mask in &WIN_MASKS {
55        if (position_mask & mask != 0) && (opponent_shape_bits & mask != 0) {
56            return false;
57        }
58    }
59    true
60}
61
62/// Count `player`'s legal moves, 0 if it is not `player`'s turn.
63///
64/// Quantik is strictly turn-alternating, so this is 0 whenever `player` is
65/// not the side to move.
66pub fn count_legal_moves(bb: &Bitboard, player: u8) -> usize {
67    if current_player(bb) != Some(player) {
68        return 0;
69    }
70    generate_legal_moves(bb).len()
71}
72
73/// Compute the 6-dimensional handcrafted feature vector for `bb`.
74///
75/// Features are from `player`'s perspective (see [`FEATURE_NAMES`]), but
76/// `player` need not be the side to move: it is simply the perspective the
77/// caller wants scored. `bb` should be non-terminal.
78pub fn features(bb: &Bitboard, player: u8) -> [f64; 6] {
79    let counts: [[u32; 4]; 2] =
80        std::array::from_fn(|p| std::array::from_fn(|s| bb.shape_piece_count(p as u8, s as u8)));
81    let side_to_move = current_player(bb).unwrap_or(0);
82    let sign = if side_to_move == player { 1.0 } else { -1.0 };
83
84    let union_all = bb.occupied();
85    let shape_unions: [u16; 4] = std::array::from_fn(|s| bb.planes[s] | bb.planes[s + 4]);
86
87    let mut threat_own = 0.0;
88    let mut threat_opp = 0.0;
89    let mut threat_shared = 0.0;
90    let mut build_two = 0.0;
91    let mut build_one = 0.0;
92
93    for &mask in &WIN_MASKS {
94        let present: Vec<u8> = (0..4u8)
95            .filter(|&s| shape_unions[s as usize] & mask != 0)
96            .collect();
97        let occupied = (union_all & mask).count_ones();
98
99        if (present.len() as u32) < occupied {
100            continue; // dead line: some shape repeats, can never be 4-distinct
101        }
102
103        if occupied == 3 {
104            let missing_shape = (0..4u8).find(|s| !present.contains(s)).unwrap();
105            let empty_position = (mask & !union_all).trailing_zeros() as u8;
106            let completable: [bool; 2] = std::array::from_fn(|side| {
107                counts[side][missing_shape as usize] < 2
108                    && placement_is_legal(bb, side as u8, missing_shape, empty_position)
109            });
110            if completable[player as usize] {
111                threat_own += 1.0;
112            }
113            if completable[1 - player as usize] {
114                threat_opp += 1.0;
115            }
116            if completable[0] && completable[1] {
117                threat_shared += sign;
118            }
119        } else if occupied == 2 {
120            build_two += sign;
121        } else if occupied == 1 {
122            build_one += sign;
123        }
124    }
125
126    let mobility_diff =
127        count_legal_moves(bb, player) as f64 - count_legal_moves(bb, 1 - player) as f64;
128
129    [
130        threat_own,
131        threat_opp,
132        threat_shared,
133        mobility_diff,
134        build_two,
135        build_one,
136    ]
137}
138
139/// Score a non-terminal position as `cfg.weights · features(bb, player)`.
140pub fn evaluate(bb: &Bitboard, player: u8, cfg: &EvalConfig) -> f64 {
141    features(bb, player)
142        .iter()
143        .zip(cfg.weights.iter())
144        .map(|(f, w)| f * w)
145        .sum()
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn empty_board_features_are_zero_except_nothing() {
154        // No lines occupied, both mobility counts resolve to 64 - 0.
155        let feats = features(&Bitboard::EMPTY, 0);
156        assert_eq!(feats[0], 0.0); // threat_own
157        assert_eq!(feats[1], 0.0); // threat_opp
158        assert_eq!(feats[2], 0.0); // threat_shared
159        assert_eq!(feats[3], 64.0); // p0 to move: 64 legal moves vs 0
160        assert_eq!(feats[4], 0.0); // build_two
161        assert_eq!(feats[5], 0.0); // build_one
162    }
163
164    /// A@0, B@1, C@2 (all p0), d@8, d@13 (p1). Row 0 misses D at position 3;
165    /// p1 already spent both d pieces, so only p0 can complete the line.
166    fn one_sided_threat_board() -> Bitboard {
167        Bitboard::EMPTY
168            .with_move(0, 0, 0)
169            .with_move(1, 3, 8)
170            .with_move(0, 2, 2)
171            .with_move(1, 3, 13)
172            .with_move(0, 1, 1)
173    }
174
175    #[test]
176    fn one_sided_threat_counts() {
177        let bb = one_sided_threat_board();
178        // p0 = 3 pieces, p1 = 2 pieces: p1 to move, so sign for player 0 is -1.
179        assert_eq!(current_player(&bb), Some(1));
180        let n = generate_legal_moves(&bb).len() as f64;
181
182        let feats_p0 = features(&bb, 0);
183        // Lines: row0 threat (p0 only); col0, col1, zone TL build_two (3);
184        // row2, row3, col2, zone TR build_one (4); zone BL dead (two d's).
185        assert_eq!(feats_p0[0], 1.0, "threat_own for p0");
186        assert_eq!(feats_p0[1], 0.0, "threat_opp for p0");
187        assert_eq!(feats_p0[2], 0.0, "threat_shared");
188        assert_eq!(feats_p0[3], -n, "mobility: not p0's turn");
189        assert_eq!(feats_p0[4], -3.0, "build_two with sign -1");
190        assert_eq!(feats_p0[5], -4.0, "build_one with sign -1");
191
192        let feats_p1 = features(&bb, 1);
193        assert_eq!(feats_p1[0], 0.0, "threat_own for p1");
194        assert_eq!(feats_p1[1], 1.0, "threat_opp for p1");
195        assert_eq!(feats_p1[3], n, "mobility: p1's turn");
196        assert_eq!(feats_p1[4], 3.0);
197        assert_eq!(feats_p1[5], 4.0);
198    }
199
200    #[test]
201    fn features_do_not_mutate_input() {
202        let bb = one_sided_threat_board();
203        let before = bb;
204        let _ = features(&bb, 0);
205        assert_eq!(bb, before);
206    }
207
208    #[test]
209    fn evaluate_matches_hand_computed_dot_product() {
210        let bb = one_sided_threat_board();
211        let cfg = EvalConfig::default();
212        let feats = features(&bb, 0);
213        let expected: f64 = feats
214            .iter()
215            .zip(cfg.weights.iter())
216            .map(|(f, w)| f * w)
217            .sum();
218        assert_eq!(evaluate(&bb, 0, &cfg), expected);
219        // Concrete: 1*100 + 0*-100 + 0*20 + (-n)*3 + -3*2 + -4*0
220        let n = generate_legal_moves(&bb).len() as f64;
221        assert_eq!(expected, 100.0 - 3.0 * n - 6.0);
222    }
223
224    #[test]
225    fn count_legal_moves_zero_off_turn() {
226        assert_eq!(count_legal_moves(&Bitboard::EMPTY, 1), 0);
227        assert_eq!(count_legal_moves(&Bitboard::EMPTY, 0), 64);
228    }
229
230    #[test]
231    fn shared_threat_is_counted_for_both() {
232        // A@0, b@1, C@2: row 0 misses D at 3, both sides still hold D pieces.
233        let bb = Bitboard::EMPTY
234            .with_move(0, 0, 0)
235            .with_move(1, 1, 1)
236            .with_move(0, 2, 2);
237        // p1 to move: sign for player 1 is +1.
238        let feats = features(&bb, 1);
239        assert_eq!(feats[0], 1.0);
240        assert_eq!(feats[1], 1.0);
241        assert_eq!(feats[2], 1.0);
242    }
243}