Skip to main content

needle_core/v3/
config.rs

1//! Needle 3 geometry.
2//!
3//! Everything here is read from the container header, never assumed. The
4//! differences that matter against v2 are asymmetric query/value head widths,
5//! a per-layer choice between sliding and global attention, and a causal
6//! depthwise convolution over Q, K and V.
7
8extern crate alloc;
9
10use crate::v3::cache::KvPrecision;
11use alloc::vec::Vec;
12
13/// Engram placement and table layout.
14#[derive(Debug, Clone, PartialEq)]
15pub struct V3Engram {
16    /// N-gram orders, `(2, 3)` on the shipped model.
17    pub orders: Vec<usize>,
18    /// Hash heads per order. `num_tables == orders.len() * heads`.
19    pub heads: usize,
20    /// Rows per table.
21    pub slots: usize,
22    /// Width of one table row, `d_model / (orders.len() * heads)`.
23    pub sub_dim: usize,
24    /// Layers carrying an Engram, `(3, 7, 11, 15, 19)` on the shipped model.
25    pub sites: Vec<usize>,
26    pub conv_taps: usize,
27    pub conv_dilation: usize,
28    pub seed_heads: usize,
29}
30
31impl V3Engram {
32    /// Total hash tables: one per (order, head) pair.
33    pub fn num_tables(&self) -> usize {
34        self.orders.len() * self.heads
35    }
36
37    /// Index of `layer` among the Engram sites, if it carries one.
38    pub fn site_of(&self, layer: usize) -> Option<usize> {
39        self.sites.iter().position(|&l| l == layer)
40    }
41}
42
43/// Full v3 model geometry.
44#[derive(Debug, Clone, PartialEq)]
45pub struct V3Config {
46    pub vocab_size: usize,
47    /// Rows of the tied head actually used for logits. Rows beyond this are
48    /// input-only code embeddings. `0` means the full vocabulary.
49    pub out_vocab: usize,
50    pub d_model: usize,
51    pub num_heads: usize,
52    pub num_kv_heads: usize,
53    pub num_layers: usize,
54    /// Query and key head width — 48 on the shipped model.
55    pub qk_head_dim: usize,
56    /// Value head width — 64. Unlike v2, this differs from `qk_head_dim`.
57    pub v_head_dim: usize,
58    pub max_seq_len: usize,
59    /// Hadamard width in HadamardMLP: `d_model` rounded up to a power of two.
60    pub hada_n: usize,
61    pub mhc_lanes: usize,
62    pub rope_theta: f32,
63    /// Local attention width for layers that are not global. `0` means full
64    /// causal everywhere.
65    pub sliding_window: usize,
66    /// Layers attending over the whole sequence instead of `sliding_window`.
67    pub global_layers: Vec<usize>,
68    /// Causal depthwise convolution width over Q, K and V. `0` means none.
69    pub qkv_conv_taps: usize,
70    pub engram: V3Engram,
71}
72
73impl V3Config {
74    /// Width of the concatenated query projection, `num_heads * qk_head_dim`.
75    pub fn q_dim(&self) -> usize {
76        self.num_heads * self.qk_head_dim
77    }
78
79    /// Width of the concatenated key projection.
80    pub fn k_dim(&self) -> usize {
81        self.num_kv_heads * self.qk_head_dim
82    }
83
84    /// Width of the concatenated value projection.
85    pub fn v_dim(&self) -> usize {
86        self.num_kv_heads * self.v_head_dim
87    }
88
89    /// Width of the attention output before `out_proj`, `num_heads * v_head_dim`.
90    pub fn attn_out_dim(&self) -> usize {
91        self.num_heads * self.v_head_dim
92    }
93
94    /// Query heads served by one key/value head.
95    pub fn kv_repeat(&self) -> usize {
96        self.num_heads / self.num_kv_heads
97    }
98
99    /// Flattened mHC lane-stream width, `mhc_lanes * d_model`.
100    pub fn mhc_width(&self) -> usize {
101        self.mhc_lanes * self.d_model
102    }
103
104    /// Rows of the tied head used for logits.
105    pub fn logit_rows(&self) -> usize {
106        if self.out_vocab == 0 {
107            self.vocab_size
108        } else {
109            self.out_vocab
110        }
111    }
112
113    /// True when `layer` attends over the whole sequence.
114    pub fn is_global(&self, layer: usize) -> bool {
115        self.global_layers.contains(&layer)
116    }
117
118    /// How many past positions `layer` can attend to, counting the current
119    /// one. `None` means unbounded — the caller must keep full history.
120    ///
121    /// This is the distinction that drives the KV allocation: local layers
122    /// need a fixed ring, global layers need everything.
123    pub fn attention_span(&self, layer: usize) -> Option<usize> {
124        if self.is_global(layer) || self.sliding_window == 0 {
125            None
126        } else {
127            Some(self.sliding_window)
128        }
129    }
130
131    /// The lane layer `i` is assigned to, as `eye(n)[i % n]` in the
132    /// reference `Stack`.
133    pub fn active_lane(&self, layer: usize) -> usize {
134        layer % self.mhc_lanes
135    }
136
137    /// Bytes of key/value cache for a session of `seq_len` positions, at
138    /// `bytes_per` per stored scalar.
139    ///
140    /// Local layers cost a fixed ring; global layers cost the real sequence
141    /// length, which is why they are sized to what a session actually uses
142    /// rather than to `max_seq_len`.
143    pub fn kv_bytes(&self, seq_len: usize, bytes_per: usize) -> usize {
144        let per_pos = self.num_kv_heads * (self.qk_head_dim + self.v_head_dim);
145        let mut total = 0;
146        for layer in 0..self.num_layers {
147            let slots = match self.attention_span(layer) {
148                Some(w) => seq_len.min(w),
149                None => seq_len,
150            };
151            total += slots * per_pos * bytes_per;
152        }
153        total
154    }
155
156    /// Bytes of key/value cache for a session of `seq_len` positions at a
157    /// given storage precision.
158    ///
159    /// Prefer this to [`Self::kv_bytes`] when the answer decides whether a
160    /// session fits: the int8 figure is not simply a quarter, because each
161    /// stored head vector also carries an `f32` scale.
162    pub fn kv_bytes_at(&self, seq_len: usize, precision: KvPrecision) -> usize {
163        match precision {
164            KvPrecision::F32 => self.kv_bytes(seq_len, 4),
165            KvPrecision::Int8 => {
166                let per_pos = self.num_kv_heads * (self.qk_head_dim + self.v_head_dim)
167                    + self.num_kv_heads * 8;
168                let mut total = 0;
169                for layer in 0..self.num_layers {
170                    let slots = match self.attention_span(layer) {
171                        Some(w) => seq_len.min(w),
172                        None => seq_len,
173                    };
174                    total += slots * per_pos;
175                }
176                total
177            }
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use alloc::vec;
186
187    /// The geometry the shipped `needle3.cact` declares.
188    fn shipped() -> V3Config {
189        V3Config {
190            vocab_size: 8192,
191            out_vocab: 8192,
192            d_model: 768,
193            num_heads: 12,
194            num_kv_heads: 2,
195            num_layers: 20,
196            qk_head_dim: 48,
197            v_head_dim: 64,
198            max_seq_len: 8192,
199            hada_n: 1024,
200            mhc_lanes: 4,
201            rope_theta: 100_000.0,
202            sliding_window: 1024,
203            global_layers: vec![4, 9, 14, 19],
204            qkv_conv_taps: 3,
205            engram: V3Engram {
206                orders: vec![2, 3],
207                heads: 3,
208                slots: 18432,
209                sub_dim: 128,
210                sites: vec![3, 7, 11, 15, 19],
211                conv_taps: 4,
212                conv_dilation: 3,
213                seed_heads: 0,
214            },
215        }
216    }
217
218    #[test]
219    fn projection_widths_match_the_container() {
220        let c = shipped();
221        // Verified against the shipped directory: q_proj is 576x768,
222        // k_proj 96x768, v_proj 128x768.
223        assert_eq!(c.q_dim(), 576);
224        assert_eq!(c.k_dim(), 96);
225        assert_eq!(c.v_dim(), 128);
226        assert_eq!(c.attn_out_dim(), 768);
227        assert_eq!(c.kv_repeat(), 6);
228    }
229
230    #[test]
231    fn engram_tables_are_orders_times_heads() {
232        // The container reports num_engram_tables 6 while upstream's
233        // engram_geometry returns 3; the 3 is the head count. Upstream:
234        // heads = d_model // (len(orders) * 128) = 768 // 256 = 3.
235        let c = shipped();
236        assert_eq!(c.engram.num_tables(), 6);
237        assert_eq!(c.d_model / (c.engram.orders.len() * c.engram.sub_dim), 3);
238        assert_eq!(c.engram.site_of(11), Some(2));
239        assert_eq!(c.engram.site_of(12), None);
240    }
241
242    #[test]
243    fn global_layers_have_no_attention_span() {
244        let c = shipped();
245        for l in [4, 9, 14, 19] {
246            assert!(c.is_global(l), "layer {l} should be global");
247            assert_eq!(c.attention_span(l), None, "layer {l} span");
248        }
249        for l in [0, 3, 10, 18] {
250            assert!(!c.is_global(l));
251            assert_eq!(c.attention_span(l), Some(1024), "layer {l} span");
252        }
253    }
254
255    #[test]
256    fn kv_cost_is_paid_for_what_a_session_uses() {
257        let c = shipped();
258        // A typical tool-calling session is a few hundred positions. Sizing
259        // the global layers to the sequence rather than to max_seq_len is the
260        // difference between a browser-viable session and a 28 MB cache.
261        let short = c.kv_bytes(512, 1);
262        let full = c.kv_bytes(8192, 1);
263        // Measured: 2.2 MB against 10.5 MB at int8. Sizing to the session
264        // rather than to max_seq_len is a 4.8x saving on a typical prompt.
265        assert_eq!(short, 512 * 20 * 224);
266        assert_eq!(full, (16 * 1024 + 4 * 8192) * 224);
267        assert!(
268            short * 4 < full,
269            "a 512-token session should cost far less than a full-context one: \
270             {short} vs {full}"
271        );
272        // Local layers saturate at the window; only the 4 global ones grow.
273        let at_window = c.kv_bytes(1024, 1);
274        let beyond = c.kv_bytes(2048, 1);
275        let per_pos = c.num_kv_heads * (c.qk_head_dim + c.v_head_dim);
276        assert_eq!(
277            beyond - at_window,
278            c.global_layers.len() * 1024 * per_pos,
279            "past the window only the global layers should keep growing"
280        );
281    }
282}