qualia_core_db/inference/kv_dict.rs
1//! W5b Phase 4b — the KV-dictionary type and its sparse **codec**, in CORE.
2//!
3//! This is the "engine runs the certified artifact" half of the sparse-KV-dictionary work. A learned
4//! per-layer dictionary `D` of `n_atoms` unit atoms represents each KV vector as a **k-sparse** linear
5//! combination — `k` (atom_index, coefficient) pairs. The engine needs two operations at runtime, and
6//! neither may depend on the forge feature:
7//! * [`KvDictionary::encode`] — Orthogonal Matching Pursuit: a vector → its k-sparse code (write path).
8//! * [`KvDictionary::reconstruct`] — code → vector (read path, in attention).
9//!
10//! The MOD **learner** that PRODUCES a dictionary is a forge/training step and lives in
11//! `wgsl_forge::calibration::kv_dictionary` (which re-exports this type). Colocation would blur "forge
12//! produces, engine runs", so only the data + codec + the small numeric helpers they share live here.
13//! Pure CPU + `f32`; the GPU reconstruction shader (Phase 4b step 5) mirrors [`reconstruct`] in WGSL.
14
15#![cfg(any(not(target_arch = "wasm32"), feature = "wasm-llm"))]
16
17/// A learned per-layer KV dictionary: `n_atoms` atoms, each of length `dim`, row-major in `atoms`.
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19pub struct KvDictionary {
20 pub dim: usize,
21 pub n_atoms: usize,
22 /// `n_atoms × dim`, row-major; each atom is L2-normalized.
23 pub atoms: Vec<f32>,
24 /// Sparsity `k` this dictionary was trained for (atoms per coded vector).
25 pub sparsity: usize,
26}
27
28/// A K-sparse code of one vector: `indices[i]` selects an atom, `coeffs[i]` its weight.
29#[derive(Debug, Clone, PartialEq)]
30pub struct SparseCode {
31 pub indices: Vec<u32>,
32 pub coeffs: Vec<f32>,
33}
34
35impl KvDictionary {
36 #[inline]
37 pub(crate) fn atom(&self, a: usize) -> &[f32] {
38 &self.atoms[a * self.dim..(a + 1) * self.dim]
39 }
40
41 /// Normalize every dictionary atom in place after loading or training.
42 /// Returns `false` when the declared shape is inconsistent or an atom has
43 /// effectively zero magnitude.
44 pub fn normalize_atoms(&mut self) -> bool {
45 if self.dim == 0 || self.atoms.len() != self.dim.saturating_mul(self.n_atoms) {
46 return false;
47 }
48 for atom in self.atoms.chunks_exact_mut(self.dim) {
49 if l2(atom) <= 1e-12 {
50 return false;
51 }
52 normalize(atom);
53 }
54 true
55 }
56
57 /// Reconstruct a vector from its sparse code: `Σ coeffs[i] · atom(indices[i])`. The read-path
58 /// operation the attention shader mirrors.
59 pub fn reconstruct(&self, code: &SparseCode) -> Vec<f32> {
60 let mut out = vec![0f32; self.dim];
61 for (idx, &c) in code.indices.iter().zip(&code.coeffs) {
62 let atom = self.atom(*idx as usize);
63 for (o, &a) in out.iter_mut().zip(atom) {
64 *o += c * a;
65 }
66 }
67 out
68 }
69
70 /// Orthogonal Matching Pursuit: encode `v` with at most `k` atoms. Greedy selection by maximum
71 /// absolute correlation with the residual, re-solving all selected coefficients by least squares
72 /// each step (the "orthogonal" in OMP). Stops early if the residual is already ~0.
73 pub fn encode(&self, v: &[f32], k: usize) -> SparseCode {
74 debug_assert_eq!(v.len(), self.dim);
75 let k = k.min(self.n_atoms).max(1);
76 let mut residual = v.to_vec();
77 let mut selected: Vec<usize> = Vec::with_capacity(k);
78 let mut coeffs: Vec<f32> = Vec::with_capacity(k);
79
80 for _ in 0..k {
81 // Pick the atom most correlated with the current residual (excluding already-picked).
82 let mut best = usize::MAX;
83 let mut best_abs = 0f32;
84 for a in 0..self.n_atoms {
85 if selected.contains(&a) {
86 continue;
87 }
88 let corr = dot(self.atom(a), &residual);
89 if corr.abs() > best_abs {
90 best_abs = corr.abs();
91 best = a;
92 }
93 }
94 if best == usize::MAX || best_abs <= 1e-12 {
95 break;
96 }
97 selected.push(best);
98 // Re-solve coefficients for ALL selected atoms by least squares (normal equations on the
99 // small |S|×|S| Gram matrix), then recompute the residual.
100 coeffs = least_squares_coeffs(&selected, self, v);
101 residual = v.to_vec();
102 for (&s, &c) in selected.iter().zip(&coeffs) {
103 let atom = self.atom(s);
104 for (r, &a) in residual.iter_mut().zip(atom) {
105 *r -= c * a;
106 }
107 }
108 if l2(&residual) <= 1e-8 {
109 break;
110 }
111 }
112 SparseCode {
113 indices: selected.iter().map(|&s| s as u32).collect(),
114 coeffs,
115 }
116 }
117}
118
119/// Pack a `(atom_index, coefficient)` pair into one 32-bit KV code word — `u16 atom-index (high) |
120/// f16 coeff (low)` — stored as an `f32` in the dict-mode KV arena. The GPU attention shader unpacks the
121/// coeff with `unpack2x16float(word).x` and the index with `word >> 16`.
122#[inline]
123pub fn pack_code_word(index: u32, coeff: f32) -> f32 {
124 let w = (index << 16) | (half::f16::from_f32(coeff).to_bits() as u32);
125 f32::from_bits(w)
126}
127
128/// Inverse of [`pack_code_word`]: `(atom_index, coefficient)` from a code word.
129#[inline]
130pub fn unpack_code_word(word: f32) -> (usize, f32) {
131 let w = word.to_bits();
132 (
133 (w >> 16) as usize,
134 half::f16::from_bits((w & 0xFFFF) as u16).to_f32(),
135 )
136}
137
138impl KvDictionary {
139 /// Encode `vec` to `k` contiguous code words in `out` (len ≥ `k`). Pads with zero-coeff words if OMP
140 /// selected fewer than `k` atoms (a zero coeff reconstructs to nothing).
141 pub fn encode_to_words(&self, vec: &[f32], k: usize, out: &mut [f32]) {
142 let code = self.encode(vec, k);
143 for (i, slot) in out.iter_mut().enumerate().take(k) {
144 let (ai, ci) = if i < code.indices.len() {
145 (code.indices[i], code.coeffs[i])
146 } else {
147 (0, 0.0)
148 };
149 *slot = pack_code_word(ai, ci);
150 }
151 }
152
153 /// Reconstruct a vector from `k` contiguous code words (`words` len ≥ `k`) into `out` (len = `dim`).
154 /// The exact `f16`-coefficient inverse of [`encode_to_words`] — the compressed-cache read path.
155 pub fn reconstruct_from_words(&self, words: &[f32], k: usize, out: &mut [f32]) {
156 for o in out.iter_mut() {
157 *o = 0.0;
158 }
159 for &word in words.iter().take(k) {
160 let (ai, ci) = unpack_code_word(word);
161 if ci != 0.0 && ai < self.n_atoms {
162 let atom = self.atom(ai);
163 for (o, &a) in out.iter_mut().zip(atom) {
164 *o += ci * a;
165 }
166 }
167 }
168 }
169}
170
171/// Least-squares coefficients for the selected atoms fitting `v`: solve `(DₛᵀDₛ) c = Dₛᵀv` via the
172/// normal equations (Gaussian elimination on the small `|S|×|S|` system).
173fn least_squares_coeffs(selected: &[usize], dict: &KvDictionary, v: &[f32]) -> Vec<f32> {
174 let s = selected.len();
175 // Gram matrix G = Dₛᵀ Dₛ (s×s) and rhs = Dₛᵀ v (s).
176 let mut g = vec![0f32; s * s];
177 let mut rhs = vec![0f32; s];
178 for i in 0..s {
179 let ai = dict.atom(selected[i]);
180 rhs[i] = dot(ai, v);
181 for j in i..s {
182 let aj = dict.atom(selected[j]);
183 let val = dot(ai, aj);
184 g[i * s + j] = val;
185 g[j * s + i] = val;
186 }
187 }
188 solve_spd(&mut g, &mut rhs, s);
189 rhs
190}
191
192/// Solve a small dense symmetric system `G c = b` in place via Gaussian elimination with partial
193/// pivoting (SPD in exact arithmetic; the pivoting + tiny ridge keep it robust to near-singular Gram
194/// matrices from correlated atoms). Result written back into `b`.
195fn solve_spd(g: &mut [f32], b: &mut [f32], n: usize) {
196 // Tiny ridge for numerical stability against duplicate/near-duplicate atoms.
197 for i in 0..n {
198 g[i * n + i] += 1e-6;
199 }
200 for col in 0..n {
201 // Partial pivot.
202 let mut piv = col;
203 let mut piv_abs = g[col * n + col].abs();
204 for r in (col + 1)..n {
205 let a = g[r * n + col].abs();
206 if a > piv_abs {
207 piv_abs = a;
208 piv = r;
209 }
210 }
211 if piv != col {
212 for c in 0..n {
213 g.swap(col * n + c, piv * n + c);
214 }
215 b.swap(col, piv);
216 }
217 let d = g[col * n + col];
218 if d.abs() <= 1e-12 {
219 continue;
220 }
221 for r in 0..n {
222 if r == col {
223 continue;
224 }
225 let factor = g[r * n + col] / d;
226 if factor == 0.0 {
227 continue;
228 }
229 for c in col..n {
230 g[r * n + c] -= factor * g[col * n + c];
231 }
232 b[r] -= factor * b[col];
233 }
234 }
235 for i in 0..n {
236 let d = g[i * n + i];
237 if d.abs() > 1e-12 {
238 b[i] /= d;
239 } else {
240 b[i] = 0.0;
241 }
242 }
243}
244
245#[inline]
246pub(crate) fn dot(a: &[f32], b: &[f32]) -> f32 {
247 a.iter().zip(b).map(|(x, y)| x * y).sum()
248}
249
250#[inline]
251pub(crate) fn l2(v: &[f32]) -> f32 {
252 dot(v, v).sqrt()
253}
254
255#[inline]
256pub(crate) fn normalize(v: &mut [f32]) {
257 let n = l2(v);
258 if n > 1e-12 {
259 for x in v.iter_mut() {
260 *x /= n;
261 }
262 }
263}