Skip to main content

figrid_board/
pattern_dense.rs

1//! Pattern4 dense embedding for the noru `dense_input` projection branch.
2//!
3//! This module bridges figrid's incrementally-maintained per-cell pattern
4//! IDs (`Board::line_pattern_ids`) into the small dense vector that noru
5//! 2.2's optional dense projection branch consumes (`dense_input` argument
6//! to `TrainableWeights::forward` / `Accumulator::apply_dense_input`).
7//!
8//! Architecture (Phase A.1, 2026-05-07):
9//!
10//! 1. Each one of the `pattern_table::PATTERN_NUM_IDS` mapped IDs is
11//!    associated with a `PATTERN4_DENSE_DIM`-vector of `f32` weights —
12//!    the *embedding* table. The table is initialized once, deterministically
13//!    seeded, and treated as **frozen** at inference time. Parameters
14//!    learned downstream live in noru's `dense_to_acc` weights, not here;
15//!    the embedding is a fixed random projection (Rahimi-Recht style)
16//!    that lets noru's small dense branch consume figrid's mapped-id
17//!    Pattern4 vocabulary.
18//! 2. For every position the dense input vector is the *sum* of the
19//!    embeddings looked up at every (cell, direction) of the board, i.e.
20//!    a bag-of-Pattern4-IDs pooled into a single `[f32; PATTERN4_DENSE_DIM]`
21//!    representation. This pool is recomputed from `board.line_pattern_ids`
22//!    on demand (~ 225 cells × 4 directions × 64 dims ≈ 58K float adds).
23//!    Incremental updates are possible but deferred until measurement
24//!    shows the recompute cost matters in practice.
25//! 3. At training time the same embedding table must be reproduced
26//!    bit-for-bit so that `dense_input` seen by the trainer matches what
27//!    figrid sees at runtime; the seed and Kaiming-style scale below are
28//!    the only knobs that need to stay in sync.
29
30use crate::pattern_table::PATTERN_NUM_IDS;
31use noru::trainer::SimpleRng;
32use std::sync::OnceLock;
33
34/// Dense projection width fed into `noru::Accumulator::apply_dense_input`.
35/// Matches `NnueConfig::dense_input_size` of any weight file produced
36/// for figrid's Phase A.1 architecture; v52 / v0.7.0 weights have
37/// `dense_input_size == 0` and the dense path is a no-op for them.
38pub const PATTERN4_DENSE_DIM: usize = 64;
39
40/// Deterministic seed used to materialize the frozen embedding table.
41/// Must match the value used by the trainer to generate identical
42/// per-position `dense_input` vectors.
43const EMBEDDING_SEED: u64 = 0x4F4D_4F4B_5530_3531; // ASCII "OMOKU051"
44
45/// Frozen `PATTERN_NUM_IDS x PATTERN4_DENSE_DIM` embedding table. Stored
46/// flat in row-major order for cache-friendly `(pid, dim)` access.
47pub struct Pattern4Embedding {
48    table: Vec<f32>,
49}
50
51impl Pattern4Embedding {
52    /// Returns a 64-dim slice for `pid`. Caller must keep `pid` within
53    /// `PATTERN_NUM_IDS`; the lookup is a flat slice index.
54    #[inline]
55    pub fn row(&self, pid: u16) -> &[f32] {
56        let start = (pid as usize) * PATTERN4_DENSE_DIM;
57        &self.table[start..start + PATTERN4_DENSE_DIM]
58    }
59
60    fn build_random() -> Self {
61        // Kaiming-flavored small init. The scale matches the dense_to_acc
62        // initialization in noru so the two halves of the projection
63        // contribute on similar magnitudes during the first epoch.
64        let scale = (2.0f32 / PATTERN4_DENSE_DIM as f32).sqrt() * 0.1;
65        let mut rng = SimpleRng::new(EMBEDDING_SEED);
66        let n = PATTERN_NUM_IDS * PATTERN4_DENSE_DIM;
67        let mut table = vec![0.0f32; n];
68        for v in table.iter_mut() {
69            *v = rng.next_normal() * scale;
70        }
71        Self { table }
72    }
73}
74
75/// Process-wide singleton holding the frozen embedding. The first
76/// access pays the build cost (a few ms for PATTERN_NUM_IDS × 64 samples);
77/// every subsequent reader sees the same table without lock contention.
78pub fn embedding() -> &'static Pattern4Embedding {
79    static TABLE: OnceLock<Pattern4Embedding> = OnceLock::new();
80    TABLE.get_or_init(Pattern4Embedding::build_random)
81}
82
83/// Sum-pool the embedding over every `(cell, direction)` slot tracked by
84/// `Board::line_pattern_ids`. Returns the `dense_input` vector callers
85/// hand to `noru::Accumulator::apply_dense_input`.
86///
87/// `line_pattern_ids` is in black-relative orientation, so this function
88/// returns the same vector regardless of side to move; perspective-aware
89/// callers can mirror via `pattern_table::swap_mapped_id` when needed.
90pub fn pool_dense_input(line_pattern_ids: &[[u16; 4]]) -> [f32; PATTERN4_DENSE_DIM] {
91    let mut out = [0.0f32; PATTERN4_DENSE_DIM];
92    let emb = embedding();
93    for cell_dirs in line_pattern_ids {
94        for &pid in cell_dirs {
95            let row = emb.row(pid);
96            for k in 0..PATTERN4_DENSE_DIM {
97                out[k] += row[k];
98            }
99        }
100    }
101    out
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::board::{Board, to_idx};
108
109    #[test]
110    fn embedding_table_has_expected_shape() {
111        let emb = embedding();
112        // Every pid yields a 64-dim slice without panicking.
113        for pid in 0..PATTERN_NUM_IDS as u16 {
114            assert_eq!(emb.row(pid).len(), PATTERN4_DENSE_DIM);
115        }
116    }
117
118    #[test]
119    fn embedding_is_deterministic_across_threads() {
120        // Two reads of the same row return identical bytes; OnceLock
121        // guarantees the table is built exactly once.
122        let a = embedding().row(123).to_vec();
123        let b = embedding().row(123).to_vec();
124        assert_eq!(a, b);
125    }
126
127    #[test]
128    fn pool_dense_input_empty_board_matches_full_recompute() {
129        let board = Board::new();
130        let v = pool_dense_input(&board.line_pattern_ids[..]);
131        // Sanity: empty board is symmetric (no stones), so the dense
132        // vector is the sum over only the boundary-aware default IDs.
133        // Recomputing the same way must yield the identical vector.
134        let again = pool_dense_input(&board.line_pattern_ids[..]);
135        assert_eq!(v, again);
136    }
137
138    #[test]
139    fn pool_dense_input_changes_after_a_move() {
140        let mut board = Board::new();
141        let baseline = pool_dense_input(&board.line_pattern_ids[..]);
142        board.make_move(to_idx(7, 7));
143        let after_move = pool_dense_input(&board.line_pattern_ids[..]);
144        // At least one of the 64 components must change — placing a stone
145        // mutates pattern_ids in 4 directions × ~11 cells.
146        assert_ne!(baseline, after_move);
147    }
148}