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}