metrale_model_layers/layers/
ngram_embed.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: N-gram embedding (arXiv 2601.21204): the dimensions, the
4//! host-side polynomial-rolling-hash ids that select rows in the
5//! `K * (N-1)` lookup tables (`ids`), the tables (`table`) and the GPU
6//! embedding (`embed`). `ids_match_python_reference` checks the ids against
7//! `bench/ngram_ref/ngram_id_fixtures.json`, written by
8//! `bench/ngram_ref/make_fixtures.py`.
9//!
10//! Table `index = (i-2)*K + j` for n-gram size `i` and split `j`:
11//!
12//! ```text
13//!   T      = ratio * vocab_size + 2*index + 1        (table row count)
14//!   mods   = [V^1 mod T, V^2 mod T, ..., V^(i-1) mod T]
15//!   id_t   = ( x_t + Σ_{d=1..i-1} shift_d(x)_t * mods[d-1] ) mod T
16//! ```
17//!
18//! where `shift_d` is a right-shift by `d` that resets at document
19//! boundaries: a position within `d` tokens of a segment start (a segment
20//! ends at an EOS token, inclusive) contributes token id 0. The ids depend
21//! only on token ids.
22//!
23//! Owner: model-layers (n-gram embedding).
24//! Invariants:
25//! - `ngram_ids` sums in u64 and reduces once per id, so the ids are exact
26//!   only while `neighbor_num * vocab_size * T < 2^64`; the code does not
27//!   check this.
28
29/// 2026-09-25: The n-gram dimensions, read from `ModelConfig`.
30#[derive(Debug, Clone, Copy)]
31pub struct NgramDims {
32    pub vocab_size: u64,
33    pub ratio: u64,
34    /// 2026-09-25: Largest n-gram size N.
35    pub neighbor_num: usize,
36    /// 2026-09-25: Hash splits K per n-gram size.
37    pub split_num: usize,
38    pub eos_token_id: u32,
39    pub hidden_size: usize,
40}
41
42impl NgramDims {
43    pub fn from_config(c: &metrale_config::ModelConfig) -> Option<Self> {
44        if c.ngram_vocab_size_ratio == 0 {
45            return None;
46        }
47        Some(Self {
48            vocab_size: c.vocab_size as u64,
49            ratio: c.ngram_vocab_size_ratio as u64,
50            neighbor_num: c.emb_neighbor_num,
51            split_num: c.emb_split_num,
52            eos_token_id: c.eos_token_id,
53            hidden_size: c.hidden_size,
54        })
55    }
56
57    pub fn num_tables(&self) -> usize {
58        self.split_num * (self.neighbor_num - 1)
59    }
60
61    /// 2026-09-25: Per-table embedding dim, `hidden_size / num_tables()`
62    /// (integer division).
63    pub fn table_dim(&self) -> usize {
64        self.hidden_size / self.num_tables()
65    }
66
67    /// 2026-09-25: Row count of table `index`: `ratio*vocab + 2*index + 1`,
68    /// so the K tables of one n-gram size have distinct sizes.
69    pub fn table_rows(&self, index: usize) -> u64 {
70        self.ratio * self.vocab_size + 2 * index as u64 + 1
71    }
72
73    /// 2026-09-25: `V^1 mod T, ..., V^(i-1) mod T` for table (i, j).
74    pub fn vocab_mods(&self, ngram: usize, split: usize) -> Vec<u64> {
75        let index = (ngram - 2) * self.split_num + split;
76        let t = self.table_rows(index);
77        let mut mods = Vec::with_capacity(ngram - 1);
78        let mut power: u64 = 1;
79        for _ in 0..ngram - 1 {
80            power = (power * self.vocab_size) % t;
81            mods.push(power);
82        }
83        mods
84    }
85}
86
87mod embed;
88mod ids;
89mod table;
90
91pub use embed::NgramEmbedding;
92pub use ids::ngram_ids;
93pub use table::NgramTable;
94
95#[cfg(test)]
96mod tests;