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}