Skip to main content

qualia_core_db/inference/
sampler.rs

1//! W2 — exact CPU sampling chain for decode.
2//!
3//! The native decode loop selects tokens by pure greedy argmax, which is prone to the
4//! repetition collapse documented across the bench probes. This module adds a full,
5//! llama.cpp-compatible sampling chain that runs on the CPU over the full logit vector
6//! (read back once per token — acceptable at the W1 one-fence-per-token cost):
7//!
8//!   repetition/frequency/presence penalties → temperature → top-k → top-p → seeded draw
9//!
10//! Design invariants:
11//! - **Greedy is a hard short-circuit.** `temperature <= 0` returns the argmax BEFORE any
12//!   penalty or filter is applied, so greedy decode is bit-identical to the pre-W2 path and
13//!   the a1a/a1c/a1d guarantees are untouched.
14//! - **Deterministic.** The draw uses a self-contained SplitMix64 PRNG seeded from the config;
15//!   the same seed + same logits + same context reproduce the same token, on native and wasm
16//!   (no `rand`, no float transcendentals in the RNG, no platform entropy).
17//! - **Exact, not top-K-approximated.** The chain runs over the whole vocabulary; top-k / top-p
18//!   are applied as masks, never as a lossy pre-reduction on the GPU.
19//! - **Pure + zero-GPU.** Everything here is testable without a device or a model.
20
21/// Sampling parameters. `temperature <= 0.0` ⇒ greedy argmax (all other fields ignored).
22///
23/// The canonical wire form is a CBOR map (the project's CBOR-first payload substrate) — see
24/// [`SamplerConfig::to_cbor`] / [`SamplerConfig::from_cbor`]. Transports that are JSON-enveloped
25/// (e.g. the MCP boundary) carry it as a hex-encoded CBOR blob, decoded via the existing JSON
26/// string helper — no ad-hoc per-field JSON float parsing.
27#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
28pub struct SamplerConfig {
29    /// Softmax temperature. `<= 0.0` selects greedy argmax (the pre-W2 default behaviour).
30    pub temperature: f32,
31    /// Keep only the `top_k` highest-logit tokens (`0` ⇒ no top-k limit).
32    pub top_k: u32,
33    /// Nucleus threshold in `(0.0, 1.0]`; keep the smallest set whose cumulative prob ≥ `top_p`
34    /// (`>= 1.0` ⇒ no top-p limit).
35    pub top_p: f32,
36    /// Multiplicative penalty on logits of tokens present in the penalty window (`1.0` ⇒ none).
37    pub repeat_penalty: f32,
38    /// Additive penalty per prior occurrence in the window (subtracted from the logit).
39    pub freq_penalty: f32,
40    /// Additive penalty for any presence in the window (subtracted once).
41    pub presence_penalty: f32,
42    /// Number of most-recent context tokens the penalties consider (`0` ⇒ whole context).
43    pub penalty_window: u32,
44    /// PRNG seed — fixed seed ⇒ reproducible draws.
45    pub seed: u64,
46}
47
48impl Default for SamplerConfig {
49    fn default() -> Self {
50        // Default is GREEDY (temperature 0) so an unconfigured caller gets the pre-W2 behaviour.
51        Self {
52            temperature: 0.0,
53            top_k: 0,
54            top_p: 1.0,
55            repeat_penalty: 1.0,
56            freq_penalty: 0.0,
57            presence_penalty: 0.0,
58            penalty_window: 64,
59            seed: 0,
60        }
61    }
62}
63
64impl SamplerConfig {
65    /// True when this config selects the greedy argmax short-circuit.
66    #[inline]
67    pub fn is_greedy(&self) -> bool {
68        !(self.temperature > 0.0)
69    }
70
71    /// Encode as a CBOR map (the canonical payload form). Infallible for this fixed scalar schema.
72    pub fn to_cbor(&self) -> Vec<u8> {
73        let mut buf = Vec::new();
74        // ciborium only fails here on an allocator/writer error, which `Vec` never raises.
75        let _ = ciborium::into_writer(self, &mut buf);
76        buf
77    }
78
79    /// Decode from a CBOR map payload. Returns `None` on malformed CBOR or a schema mismatch —
80    /// the caller then keeps greedy decode rather than sampling on a bad config.
81    pub fn from_cbor(bytes: &[u8]) -> Option<Self> {
82        ciborium::from_reader(bytes).ok()
83    }
84
85    /// A sensible default for interactive chat / instruct generation: enough randomness to avoid the
86    /// greedy repetition-collapse, plus a light repeat penalty, while staying reproducible (fixed
87    /// seed). Benchmarks and determinism tests must NOT install this — the unconfigured global stays
88    /// greedy, preserving the bit-exact a1a/a6a guarantees.
89    pub fn chat_default() -> Self {
90        Self {
91            temperature: 0.7,
92            top_k: 40,
93            top_p: 0.95,
94            repeat_penalty: 1.1,
95            freq_penalty: 0.0,
96            presence_penalty: 0.0,
97            penalty_window: 64,
98            seed: 0,
99        }
100    }
101}
102
103/// Stateful sampler: owns the PRNG stream so successive tokens advance it deterministically.
104#[derive(Debug, Clone)]
105pub struct SamplerState {
106    pub cfg: SamplerConfig,
107    rng: u64,
108}
109
110/// SplitMix64 — tiny, allocation-free, platform-independent PRNG (Steele et al. 2014).
111#[inline]
112fn splitmix64(state: &mut u64) -> u64 {
113    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
114    let mut z = *state;
115    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
116    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
117    z ^ (z >> 31)
118}
119
120impl SamplerState {
121    pub fn new(cfg: SamplerConfig) -> Self {
122        // Avoid the all-zero SplitMix64 fixed point; mix the seed once.
123        let mut rng = cfg.seed ^ 0x2545_F491_4F6C_DD1D;
124        let _ = splitmix64(&mut rng);
125        Self { cfg, rng }
126    }
127
128    /// Uniform f32 in `[0, 1)` from the PRNG stream (53-bit mantissa precision).
129    #[inline]
130    fn next_unit(&mut self) -> f32 {
131        let bits = splitmix64(&mut self.rng) >> 11; // 53 bits
132        (bits as f64 * (1.0 / (1u64 << 53) as f64)) as f32
133    }
134
135    /// Argmax with lowest-token-id tie-break — identical selection rule to the CPU argmax path.
136    #[inline]
137    fn argmax(logits: &[f32]) -> u32 {
138        let mut best_i = 0u32;
139        let mut best_v = f32::NEG_INFINITY;
140        for (i, &v) in logits.iter().enumerate() {
141            if v > best_v {
142                best_v = v;
143                best_i = i as u32;
144            }
145        }
146        best_i
147    }
148
149    /// Sample the next token id. `logits` is the full vocab (mutated in place as a scratch buffer);
150    /// `ctx` is the running token history (prompt + generated) for the penalty window.
151    ///
152    /// Greedy (`temperature <= 0`) returns the argmax with no mutation of selection semantics.
153    pub fn sample(&mut self, logits: &mut [f32], ctx: &[u32]) -> u32 {
154        if logits.is_empty() {
155            return 0;
156        }
157        if self.cfg.is_greedy() {
158            return Self::argmax(logits);
159        }
160        let vocab = logits.len();
161
162        // 1) Penalties over the penalty window (most-recent N context tokens).
163        let (rp, fp, pp) = (
164            self.cfg.repeat_penalty,
165            self.cfg.freq_penalty,
166            self.cfg.presence_penalty,
167        );
168        if rp != 1.0 || fp != 0.0 || pp != 0.0 {
169            let window = if self.cfg.penalty_window == 0 {
170                ctx.len()
171            } else {
172                (self.cfg.penalty_window as usize).min(ctx.len())
173            };
174            // Count occurrences of each in-window token id (only ids within vocab matter).
175            // A small hashmap-free pass: walk the window, apply per occurrence.
176            let start = ctx.len() - window;
177            // presence needs "seen at least once" — track via first-touch using freq accumulation.
178            for &tok in &ctx[start..] {
179                let t = tok as usize;
180                if t >= vocab {
181                    continue;
182                }
183                let l = logits[t];
184                // repeat_penalty: divide positive logits, multiply negative (llama.cpp convention).
185                if rp != 1.0 {
186                    logits[t] = if l > 0.0 { l / rp } else { l * rp };
187                }
188                if fp != 0.0 {
189                    logits[t] -= fp; // per-occurrence frequency penalty
190                }
191            }
192            if pp != 0.0 {
193                // presence: subtract once per DISTINCT in-window id. Second pass with a seen-guard
194                // built from the window (bounded, no alloc beyond a small local set is avoided by
195                // re-scanning — window is small, and this keeps the module alloc-free).
196                for (rel, &tok) in ctx[start..].iter().enumerate() {
197                    let t = tok as usize;
198                    if t >= vocab {
199                        continue;
200                    }
201                    // apply only on first occurrence within the window
202                    let first = ctx[start..start + rel].iter().all(|&p| p != tok);
203                    if first {
204                        logits[t] -= pp;
205                    }
206                }
207            }
208        }
209
210        // 2) Temperature scale.
211        let inv_t = 1.0 / self.cfg.temperature;
212        for l in logits.iter_mut() {
213            *l *= inv_t;
214        }
215
216        // 3) Build an index list sorted by logit desc (stable tie-break by id) for top-k / top-p.
217        //    Vocab is ~50k; one sort/token is cheap next to a forward pass.
218        let mut order: Vec<u32> = (0..vocab as u32).collect();
219        order.sort_unstable_by(|&a, &b| {
220            let (la, lb) = (logits[a as usize], logits[b as usize]);
221            lb.partial_cmp(&la)
222                .unwrap_or(std::cmp::Ordering::Equal)
223                .then(a.cmp(&b))
224        });
225
226        // 4) top-k truncation.
227        let mut keep = if self.cfg.top_k > 0 {
228            (self.cfg.top_k as usize).min(vocab)
229        } else {
230            vocab
231        };
232
233        // 5) Softmax over the kept prefix (numerically stable), then top-p nucleus truncation.
234        let max_l = logits[order[0] as usize];
235        let mut probs: Vec<f32> = Vec::with_capacity(keep);
236        let mut sum = 0.0f32;
237        for &id in &order[..keep] {
238            let p = (logits[id as usize] - max_l).exp();
239            probs.push(p);
240            sum += p;
241        }
242        if sum <= 0.0 || !sum.is_finite() {
243            return order[0]; // degenerate — fall back to the top logit
244        }
245        if self.cfg.top_p < 1.0 && self.cfg.top_p > 0.0 {
246            let mut cum = 0.0f32;
247            let mut nucleus = keep;
248            for (i, p) in probs.iter().enumerate() {
249                cum += p / sum;
250                if cum >= self.cfg.top_p {
251                    nucleus = i + 1;
252                    break;
253                }
254            }
255            keep = nucleus.max(1);
256            probs.truncate(keep);
257            sum = probs.iter().sum();
258        }
259
260        // 6) Seeded categorical draw over the kept nucleus.
261        let r = self.next_unit() * sum;
262        let mut acc = 0.0f32;
263        for (i, p) in probs.iter().enumerate() {
264            acc += *p;
265            if r < acc {
266                return order[i];
267            }
268        }
269        order[keep - 1]
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn logits(vals: &[f32]) -> Vec<f32> {
278        vals.to_vec()
279    }
280
281    #[test]
282    fn t1_greedy_returns_argmax() {
283        let cfg = SamplerConfig::default(); // temperature 0 ⇒ greedy
284        let mut s = SamplerState::new(cfg);
285        let mut l = logits(&[0.1, 0.9, 0.3, 0.9]); // tie between id 1 and 3 → lowest id wins
286        assert_eq!(s.sample(&mut l, &[]), 1);
287    }
288
289    #[test]
290    fn t2_same_seed_same_sequence() {
291        let cfg = SamplerConfig {
292            temperature: 1.0,
293            seed: 42,
294            ..Default::default()
295        };
296        let base = [2.0f32, 1.0, 0.5, 0.2, -1.0, 3.0, 0.0, 1.5];
297        let draw = |seed: u64| {
298            let mut s = SamplerState::new(SamplerConfig { seed, ..cfg });
299            (0..100)
300                .map(|_| s.sample(&mut base.to_vec(), &[]))
301                .collect::<Vec<_>>()
302        };
303        assert_eq!(draw(42), draw(42), "same seed must reproduce the sequence");
304    }
305
306    #[test]
307    fn t3_different_seed_diverges() {
308        let cfg = SamplerConfig {
309            temperature: 1.5,
310            seed: 1,
311            ..Default::default()
312        };
313        let base = [1.0f32, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; // uniform ⇒ draws depend only on rng
314        let seq = |seed: u64| {
315            let mut s = SamplerState::new(SamplerConfig { seed, ..cfg });
316            (0..100)
317                .map(|_| s.sample(&mut base.to_vec(), &[]))
318                .collect::<Vec<_>>()
319        };
320        assert_ne!(
321            seq(1),
322            seq(999),
323            "different seeds should diverge on a uniform dist"
324        );
325    }
326
327    #[test]
328    fn t4_top_k_1_is_argmax_any_temperature() {
329        for temp in [0.5f32, 1.0, 2.0, 5.0] {
330            let cfg = SamplerConfig {
331                temperature: temp,
332                top_k: 1,
333                seed: 7,
334                ..Default::default()
335            };
336            let mut s = SamplerState::new(cfg);
337            let mut l = logits(&[0.2, 0.1, 2.5, 0.4, 2.5]); // argmax id 2 (tie→lowest)
338            assert_eq!(
339                s.sample(&mut l, &[]),
340                2,
341                "top_k=1 must equal argmax at T={temp}"
342            );
343        }
344    }
345
346    #[test]
347    fn t5_repeat_penalty_suppresses_repeat() {
348        // id 0 is the clear argmax; with a strong repeat penalty and it in-context, it must lose.
349        let cfg = SamplerConfig {
350            temperature: 1.0,
351            top_k: 1, // deterministic: pick the post-penalty argmax
352            repeat_penalty: 100.0,
353            penalty_window: 8,
354            seed: 3,
355            ..Default::default()
356        };
357        let mut s = SamplerState::new(cfg);
358        let mut l = logits(&[5.0, 4.0, 3.0, 2.0]);
359        let picked = s.sample(&mut l, &[0, 0, 0]); // id 0 heavily penalized
360        assert_ne!(
361            picked, 0,
362            "repeated token should be suppressed below the runner-up"
363        );
364        assert_eq!(picked, 1);
365    }
366
367    #[test]
368    fn t6_top_p_head_only() {
369        // One dominant token; top_p=0.5 must keep only it, so the draw is deterministic.
370        let cfg = SamplerConfig {
371            temperature: 1.0,
372            top_p: 0.5,
373            seed: 11,
374            ..Default::default()
375        };
376        let mut s = SamplerState::new(cfg);
377        // softmax([10,0,0,0]) ≈ [0.9999,...]; nucleus 0.5 keeps just id 0.
378        let l = logits(&[10.0, 0.0, 0.0, 0.0]);
379        for _ in 0..20 {
380            assert_eq!(s.sample(&mut l.clone(), &[]), 0);
381        }
382    }
383
384    #[test]
385    fn t7_greedy_ignores_penalties() {
386        // Greedy short-circuits BEFORE penalties — proves the a1a/a1c/a1d greedy contract holds.
387        let cfg = SamplerConfig {
388            temperature: 0.0,
389            repeat_penalty: 100.0,
390            presence_penalty: 100.0,
391            seed: 5,
392            ..Default::default()
393        };
394        let mut s = SamplerState::new(cfg);
395        let mut l = logits(&[5.0, 4.0, 3.0]);
396        assert_eq!(
397            s.sample(&mut l, &[0, 0, 0]),
398            0,
399            "greedy must ignore penalties and return argmax"
400        );
401    }
402
403    #[test]
404    fn t9_cbor_round_trip() {
405        let cfg = SamplerConfig {
406            temperature: 0.8,
407            top_k: 40,
408            top_p: 0.95,
409            repeat_penalty: 1.1,
410            freq_penalty: 0.2,
411            presence_penalty: 0.1,
412            penalty_window: 128,
413            seed: 0xDEAD_BEEF,
414        };
415        let bytes = cfg.to_cbor();
416        let back = SamplerConfig::from_cbor(&bytes).expect("cbor round-trip");
417        assert_eq!(back.temperature, cfg.temperature);
418        assert_eq!(back.top_k, cfg.top_k);
419        assert_eq!(back.top_p, cfg.top_p);
420        assert_eq!(back.repeat_penalty, cfg.repeat_penalty);
421        assert_eq!(back.freq_penalty, cfg.freq_penalty);
422        assert_eq!(back.presence_penalty, cfg.presence_penalty);
423        assert_eq!(back.penalty_window, cfg.penalty_window);
424        assert_eq!(back.seed, cfg.seed);
425        // Malformed CBOR ⇒ None (caller falls back to greedy, never samples on garbage).
426        assert!(SamplerConfig::from_cbor(&[0xFF, 0x00, 0x13, 0x37]).is_none());
427    }
428
429    #[test]
430    fn t8_presence_once_per_distinct() {
431        // presence penalty applies once per distinct id regardless of count.
432        let cfg = SamplerConfig {
433            temperature: 1.0,
434            top_k: 1,
435            presence_penalty: 1.5,
436            penalty_window: 16,
437            seed: 2,
438            ..Default::default()
439        };
440        let mut s = SamplerState::new(cfg);
441        // id0 logit 5, id1 logit 4. presence -1.5 to id0 (appears 3x, once applied) → 3.5 < 4 → id1.
442        let mut l = logits(&[5.0, 4.0, 0.0]);
443        assert_eq!(s.sample(&mut l, &[0, 0, 0]), 1);
444    }
445
446    #[test]
447    fn t10_chat_default_is_non_greedy_reproducible() {
448        let cfg = SamplerConfig::chat_default();
449        assert!(
450            !cfg.is_greedy(),
451            "chat default must sample, not fall back to greedy"
452        );
453        assert!(cfg.temperature > 0.0 && cfg.top_p <= 1.0 && cfg.repeat_penalty > 1.0);
454        // Seeded ⇒ reproducible: the same config reproduces the same draw sequence.
455        let base = [2.0f32, 1.0, 0.5, 3.0, 0.2, 1.5];
456        let run = || {
457            let mut s = SamplerState::new(SamplerConfig::chat_default());
458            (0..50)
459                .map(|_| s.sample(&mut base.to_vec(), &[]))
460                .collect::<Vec<_>>()
461        };
462        assert_eq!(
463            run(),
464            run(),
465            "chat default must be reproducible for a fixed seed"
466        );
467    }
468}