Skip to main content

cortiq_engine/
sampler.rs

1//! Token sampling — temperature, top-p, top-k, min-p, repetition penalty.
2//!
3//! Randomness comes from an explicit SplitMix64 PRNG carried by the
4//! caller: reproducible with a seed, unbiased across the whole CDF
5//! (the v1 `subsec_nanos` source could never pick past ~23% of it).
6
7use serde::{Deserialize, Serialize};
8
9/// SplitMix64 — tiny, fast, statistically solid for sampling.
10#[derive(Debug, Clone)]
11pub struct SplitMix64 {
12    state: u64,
13}
14
15/// Reusable per-pipeline sampling workspace. The epoch table lets the
16/// repetition penalty visit each token id once without allocating a HashSet
17/// or clearing a vocab-sized boolean vector on every decode step.
18#[derive(Debug, Default)]
19pub struct SamplerScratch {
20    seen_epoch: Vec<u32>,
21    epoch: u32,
22    /// Distinct-token set for the presence penalty; reused per token.
23    presence_seen: std::collections::HashSet<u32>,
24    /// The working copy of the logits. At a 129k vocab that is half a
25    /// megabyte allocated, filled and dropped per token; the struct that
26    /// exists to hold scratch may as well hold this one too.
27    probs: Vec<f32>,
28    /// The SECOND whole-vocab copy — top-k's partition buffer. Qwen3.6's
29    /// vocab is 248320, so this was another megabyte allocated, filled
30    /// and dropped per token, on the same hot path and for the same
31    /// reason. Same fix.
32    topk: Vec<f32>,
33    /// The sparse chain's candidate list and its per-grain partials.
34    cand: Vec<(u32, f32)>,
35    cand_parts: Vec<Vec<(u32, f32)>>,
36    sum_parts: Vec<f32>,
37    sparse: Sparse,
38}
39
40impl SamplerScratch {
41    fn begin_seen(&mut self, vocab_size: usize) -> u32 {
42        if self.seen_epoch.len() < vocab_size {
43            self.seen_epoch.resize(vocab_size, 0);
44        }
45        self.epoch = self.epoch.wrapping_add(1);
46        if self.epoch == 0 {
47            self.seen_epoch.fill(0);
48            self.epoch = 1;
49        }
50        self.epoch
51    }
52}
53
54impl SplitMix64 {
55    pub fn new(seed: u64) -> Self {
56        Self { state: seed }
57    }
58
59    /// Seed from OS entropy (address-space + time mix) when none given.
60    pub fn from_entropy() -> Self {
61        let t = std::time::SystemTime::now()
62            .duration_since(std::time::UNIX_EPOCH)
63            .unwrap_or_default();
64        let addr = Box::into_raw(Box::new(0u8)) as u64;
65        // SAFETY: pointer came from Box::into_raw just above.
66        unsafe { drop(Box::from_raw(addr as *mut u8)) };
67        Self::new(t.as_nanos() as u64 ^ addr.rotate_left(17) ^ 0x9E3779B97F4A7C15)
68    }
69
70    #[inline]
71    pub fn next_u64(&mut self) -> u64 {
72        self.state = self.state.wrapping_add(0x9E3779B97F4A7C15);
73        let mut z = self.state;
74        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
75        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
76        z ^ (z >> 31)
77    }
78
79    /// Uniform f32 in [0, 1).
80    #[inline]
81    pub fn next_f32(&mut self) -> f32 {
82        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
83    }
84}
85
86/// Sampling configuration.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SamplerConfig {
89    pub temperature: f32,
90    pub top_p: f32,
91    pub top_k: u32,
92    pub repetition_penalty: f32,
93    pub min_p: f32,
94    /// Flat additive penalty on every token that has appeared at least
95    /// once (OpenAI-style presence penalty). Qwen3.8's instruct sampling
96    /// asks for 1.5 here — the multiplicative repetition_penalty is a
97    /// different curve and cannot stand in for it.
98    #[serde(default)]
99    pub presence_penalty: f32,
100    /// Fixed seed for reproducible generation (None = entropy).
101    #[serde(default)]
102    pub seed: Option<u64>,
103    /// Token IDs to suppress (force logit to -inf).
104    #[serde(default)]
105    pub suppress_tokens: Vec<u32>,
106}
107
108impl Default for SamplerConfig {
109    fn default() -> Self {
110        Self {
111            temperature: 0.7,
112            top_p: 0.9,
113            top_k: 40,
114            repetition_penalty: 1.1,
115            presence_penalty: 0.0,
116            min_p: 0.05,
117            seed: None,
118            suppress_tokens: Vec::new(),
119        }
120    }
121}
122
123/// Sample next token from logits. Chain order is fixed:
124/// rep-penalty → temperature → softmax → min-p → top-k → top-p → sample.
125pub fn sample(
126    logits: &[f32],
127    config: &SamplerConfig,
128    past_tokens: &[u32],
129    rng: &mut SplitMix64,
130) -> u32 {
131    let mut scratch = SamplerScratch::default();
132    sample_with_scratch(logits, config, past_tokens, rng, &mut scratch)
133}
134
135/// Sampling entry point for hot decode loops with reusable scratch storage.
136pub fn sample_with_scratch(
137    logits: &[f32],
138    config: &SamplerConfig,
139    past_tokens: &[u32],
140    rng: &mut SplitMix64,
141    scratch: &mut SamplerScratch,
142) -> u32 {
143    sample_with_scratch_pool(logits, config, past_tokens, rng, scratch, None)
144}
145
146/// The same chain with the whole-vocab passes spread over the CPU pool.
147///
148/// WHY: at Qwen3.8's 248 320-entry vocab the serial sampler is ~14 passes
149/// over a megabyte plus 248k `exp` and a `select_nth` over a second copy
150/// — measured on the RTX 5090 pod as the gap between `bench --core` and
151/// the production loop (50.9 against 46.8 tok/s with penalty+confidence
152/// alone; the temperature path pays the softmax and the partition on top).
153/// The GPU graph owns the token, so during decode the pool sits idle —
154/// this is free work.
155///
156/// WHAT IS PRESERVED: every value. The parallel passes are elementwise
157/// (each output depends on its own input only), the sums that feed
158/// divisions stay sequential in index order, and top-k's threshold is
159/// the k-th largest VALUE — the same number `select_nth` returned. The
160/// sampled token is bit-identical to the serial chain for the same seed.
161pub fn sample_with_scratch_pool(
162    logits: &[f32],
163    config: &SamplerConfig,
164    past_tokens: &[u32],
165    rng: &mut SplitMix64,
166    scratch: &mut SamplerScratch,
167    pool: Option<&crate::pool::Pool>,
168) -> u32 {
169    if config.temperature < 1e-6
170        && config.repetition_penalty == 1.0
171        && config.presence_penalty == 0.0
172        && config.suppress_tokens.is_empty()
173    {
174        return argmax(logits);
175    }
176    // Borrowed from the scratch and handed back at the single exit: at a
177    // 129k vocab this copy is half a megabyte allocated, filled and dropped
178    // per token, and the struct that exists to hold scratch may as well
179    // hold it. Every early return goes through `done` so the buffer never
180    // leaks back to the allocator.
181    if config.temperature < 1e-6 {
182        // greedy over the penalized logits, no working copy
183        return argmax_penalized(logits, config, past_tokens, scratch, pool);
184    }
185    if sparse_ok(config) {
186        // The sparse chain: same distribution, a tenth of the passes.
187        let mut sp = std::mem::take(&mut scratch.sparse);
188        let ok = sparse_distribution_into(logits, config, past_tokens, scratch, pool, &mut sp);
189        let t = if ok {
190            draw_sparse(&sp, rng)
191        } else {
192            argmax(logits)
193        };
194        scratch.sparse = sp;
195        return t;
196    }
197    let mut probs = std::mem::take(&mut scratch.probs);
198    let normalized = chain(logits, config, past_tokens, scratch, pool, &mut probs);
199
200    let mut done = |probs: Vec<f32>, tok: u32| -> u32 {
201        scratch.probs = probs;
202        tok
203    };
204
205    if !normalized {
206        // Everything filtered out — fall back to greedy over original logits.
207        let t = argmax(logits);
208        return done(probs, t);
209    }
210    let t = categorical_sample(&probs, rng.next_f32());
211    done(probs, t)
212}
213
214/// Greedy over the PENALIZED logits without the working copy: one pass
215/// that applies the repetition / presence penalty and the suppress list
216/// on the fly (a membership table over the vocab, built from the past
217/// tokens) and keeps the argmax with the same tie rule as `argmax`
218/// (highest index among equal maxima). Bit-identical to
219/// `chain` + `argmax` for temperature 0 — the values compared are the
220/// same expressions — and it is what a greedy decode with penalties pays
221/// per token, and what a speculative round pays per draft and per
222/// verified row (nine such passes a round at k=4).
223pub fn argmax_penalized(
224    logits: &[f32],
225    config: &SamplerConfig,
226    past_tokens: &[u32],
227    scratch: &mut SamplerScratch,
228    pool: Option<&crate::pool::Pool>,
229) -> u32 {
230    let n = logits.len();
231    if config.repetition_penalty == 1.0
232        && config.presence_penalty == 0.0
233        && config.suppress_tokens.is_empty()
234    {
235        return argmax(logits);
236    }
237    // Membership: seen_epoch[i] == epoch for past tokens (each once).
238    let epoch = scratch.begin_seen(n);
239    for &tok in past_tokens {
240        let idx = tok as usize;
241        if idx < n {
242            scratch.seen_epoch[idx] = epoch;
243        }
244    }
245    let rep = config.repetition_penalty;
246    let pres = config.presence_penalty;
247    // Suppressed ids get -inf; a second, rarer set — keep it exact.
248    let suppress = &config.suppress_tokens;
249    let seen = &scratch.seen_epoch;
250    let value = |i: usize| -> f32 {
251        let mut v = logits[i];
252        if suppress.iter().any(|&t| t as usize == i) {
253            return f32::NEG_INFINITY;
254        }
255        if seen[i] == epoch {
256            if rep != 1.0 {
257                if v > 0.0 {
258                    v /= rep;
259                } else {
260                    v *= rep;
261                }
262            }
263            if pres != 0.0 {
264                v -= pres;
265            }
266        }
267        v
268    };
269    // The suppress list is scanned per element above; keep that path
270    // serial and rare. The common (no suppress) case runs the pool.
271    let best_in = |s: usize, e: usize| -> (usize, f32) {
272        let mut bi = s;
273        let mut bv = f32::NEG_INFINITY;
274        for i in s..e {
275            let v = value(i);
276            if v >= bv {
277                bv = v;
278                bi = i;
279            }
280        }
281        (bi, bv)
282    };
283    match pool {
284        Some(p) if n >= PAR_MIN && suppress.is_empty() => {
285            let m = std::sync::Mutex::new(Vec::<(usize, f32)>::new());
286            p.run_rows(n, &|s, e| {
287                let r = best_in(s, e);
288                m.lock().unwrap().push(r);
289            });
290            let mut parts = m.into_inner().unwrap();
291            // Same rule across chunks: the max value, and among equal
292            // maxima the HIGHEST index — chunk order does not matter once
293            // sorted by index.
294            parts.sort_by_key(|(i, _)| *i);
295            let mut bi = 0usize;
296            let mut bv = f32::NEG_INFINITY;
297            for (i, v) in parts {
298                if v >= bv {
299                    bv = v;
300                    bi = i;
301                }
302            }
303            bi as u32
304        }
305        _ => best_in(0, n).0 as u32,
306    }
307}
308
309/// The chain up to the draw, into `probs`: penalties, temperature,
310/// softmax, min-p, top-k, top-p, renormalize. Returns false when the
311/// filters left nothing (the caller's greedy fallback); for a greedy
312/// config it stops after the penalties (`probs` then holds penalized
313/// logits, and argmax over them is the token).
314fn chain(
315    logits: &[f32],
316    config: &SamplerConfig,
317    past_tokens: &[u32],
318    scratch: &mut SamplerScratch,
319    pool: Option<&crate::pool::Pool>,
320    probs: &mut Vec<f32>,
321) -> bool {
322    probs.clear();
323    probs.extend_from_slice(logits);
324    apply_penalties(probs, config, past_tokens, scratch);
325
326    if config.temperature < 1e-6 {
327        return true;
328    }
329    if config.temperature != 1.0 {
330        let t = config.temperature;
331        par_map(pool, probs, &move |p| p / t);
332    }
333
334    softmax_inplace_pool(pool, probs);
335
336    if config.min_p > 0.0 {
337        let max_prob = par_max(pool, probs, 0.0);
338        let threshold = max_prob * config.min_p;
339        par_map(pool, probs, &move |p| if p < threshold { 0.0 } else { p });
340    }
341
342    if config.top_k > 0 && (config.top_k as usize) < probs.len() {
343        apply_top_k_pool(pool, probs, config.top_k as usize);
344    }
345
346    if config.top_p < 1.0 && config.top_p > 0.0 {
347        apply_top_p(probs, config.top_p);
348    }
349
350    let sum: f32 = probs.iter().sum();
351    if sum > 0.0 {
352        par_map(pool, probs, &move |p| p / sum);
353        true
354    } else {
355        false
356    }
357}
358
359/// The chain's first stage — suppress, repetition and presence penalties
360/// — in place on a working copy of the logits. Every penalty only LOWERS
361/// a logit, which is what lets the sparse chain below bound its
362/// candidates.
363fn apply_penalties(
364    probs: &mut [f32],
365    config: &SamplerConfig,
366    past_tokens: &[u32],
367    scratch: &mut SamplerScratch,
368) {
369    for &tok in &config.suppress_tokens {
370        if (tok as usize) < probs.len() {
371            probs[tok as usize] = f32::NEG_INFINITY;
372        }
373    }
374    if config.repetition_penalty != 1.0 {
375        apply_repetition_penalty(probs, past_tokens, config.repetition_penalty, scratch);
376    }
377    if config.presence_penalty != 0.0 {
378        // Once per DISTINCT seen token — presence, not frequency. The
379        // scratch set the repetition penalty uses would serve, but it is
380        // only built on its own branch; a local pass stays correct when
381        // rep-penalty is 1.0 (Qwen3.8's recommended pairing).
382        let mut seen = std::mem::take(&mut scratch.presence_seen);
383        seen.clear();
384        seen.extend(past_tokens.iter().copied());
385        for &tok in &seen {
386            if (tok as usize) < probs.len() {
387                probs[tok as usize] -= config.presence_penalty;
388            }
389        }
390        scratch.presence_seen = seen;
391    }
392}
393
394fn config_penalized(config: &SamplerConfig) -> bool {
395    config.repetition_penalty != 1.0
396        || config.presence_penalty != 0.0
397        || !config.suppress_tokens.is_empty()
398}
399
400/// Largest top-k the sparse chain serves. Past this the dense chain is
401/// the better tool anyway.
402pub const SPARSE_TOPK_MAX: usize = 256;
403
404/// Whether `config` can go through the sparse chain: a real temperature
405/// and a top-k in 1..=256. Qwen's recommended instruct settings
406/// (0.7 / top-p 0.8 / top-k 20 / presence 1.5) do.
407pub fn sparse_ok(config: &SamplerConfig) -> bool {
408    config.temperature >= 1e-6 && config.top_k > 0 && (config.top_k as usize) <= SPARSE_TOPK_MAX
409}
410
411/// A distribution over at most `SPARSE_TOPK_MAX` tokens: `(id, prob)`
412/// sorted by id, probs summing to 1.
413pub type Sparse = Vec<(u32, f32)>;
414
415/// The sampler chain's distribution as a SPARSE list — the same
416/// distribution `chain` builds over the whole vocab, for configs with a
417/// top-k, at a fraction of the cost. The dense chain copies the vocab,
418/// exponentiates it, selects, filters and normalises it — six or seven
419/// passes over 248k floats — and every one of them past the selection
420/// touches only the k survivors. Here: penalties on a copy ONLY when
421/// there are penalties, one pooled pass that selects the top-k penalized
422/// logits, one pooled pass for the vocab-wide softmax denominator (top-p
423/// is defined against the FULL normalisation, so the denominator must
424/// see every token), and the rest over k entries.
425///
426/// Why it is the same distribution: softmax → min-p → top-k → top-p →
427/// renormalise, in the dense order. Softmax is monotone in the logit, so
428/// the top-k SET is the top-k of the penalized logits; min-p drops
429/// tokens below `max_prob·min_p`, i.e. below `exp((l − l_max)/T) <
430/// min_p` — every token outside the top-k that fails it is dropped
431/// either way, and the ones inside are tested exactly as the dense chain
432/// tests them; top-p cuts on the cumulative FULL-normalised probs of the
433/// survivors sorted descending, computed here from the same terms. What
434/// differs is floating-point: the denominator's summation order and the
435/// exp of `(l − l_max)/T` against `l/T − max(l/T)`. Ties at the k-th
436/// place resolve by lower id here where the dense chain keeps them all.
437///
438/// Returns false when the chain filtered everything (the dense chain's
439/// `!normalized`) — the caller falls back to greedy the same way.
440pub fn sparse_distribution_into(
441    logits: &[f32],
442    config: &SamplerConfig,
443    past_tokens: &[u32],
444    scratch: &mut SamplerScratch,
445    pool: Option<&crate::pool::Pool>,
446    out: &mut Sparse,
447) -> bool {
448    debug_assert!(sparse_ok(config));
449    out.clear();
450    let k = (config.top_k as usize).min(logits.len());
451    if k == 0 {
452        return false;
453    }
454    let penalized = config_penalized(config);
455    let mut probs = std::mem::take(&mut scratch.probs);
456    if penalized {
457        probs.clear();
458        probs.extend_from_slice(logits);
459        apply_penalties(&mut probs, config, past_tokens, scratch);
460    }
461    let src: &[f32] = if penalized { &probs } else { logits };
462    let t = if config.temperature > 0.0 {
463        config.temperature
464    } else {
465        1.0
466    };
467    let mut cand = std::mem::take(&mut scratch.cand);
468    par_topk(pool, src, k, &mut cand, &mut scratch.cand_parts);
469    // `cand` is by value descending, ties by id — the top-k AND every tie
470    // at the k-th place, as the dense chain keeps them.
471    let ok = if let Some(&(_, lmax)) = cand.first().filter(|c| c.1.is_finite()) {
472        let sum_all = par_sum_exp(pool, src, lmax, t, &mut scratch.sum_parts);
473        // e_i = exp((l_i − l_max)/T); min-p against e_i < min_p (max_prob
474        // is e = 1 over the same denominator); probs e_i / sum_all.
475        let min_p = config.min_p;
476        let mut cum = 0.0f32;
477        let mut cut = false;
478        for &(id, l) in cand.iter() {
479            if cut {
480                break;
481            }
482            let e = ((l - lmax) / t).exp();
483            if min_p > 0.0 && e < min_p {
484                continue;
485            }
486            let pr = e / sum_all;
487            if pr <= 0.0 {
488                continue;
489            }
490            out.push((id, pr));
491            cum += pr;
492            if config.top_p < 1.0 && config.top_p > 0.0 && cum >= config.top_p {
493                cut = true;
494            }
495        }
496        // renormalise over the survivors and order by id
497        let sum: f32 = out.iter().map(|c| c.1).sum();
498        if sum > 0.0 {
499            for c in out.iter_mut() {
500                c.1 /= sum;
501            }
502            out.sort_unstable_by_key(|c| c.0);
503            true
504        } else {
505            out.clear();
506            false
507        }
508    } else {
509        false
510    };
511    scratch.cand = cand;
512    scratch.probs = probs;
513    ok
514}
515
516/// The k largest of `src` as `(id, value)` sorted by value descending,
517/// ties by id ascending — INCLUDING every value tied with the k-th, which
518/// is what the dense chain's `p < threshold → 0` keeps. Two pooled passes:
519/// a per-grain k-slot selection merged in grain order for the k-th value,
520/// then a gather of everything at or above it. Nothing here depends on
521/// scheduling, so a seed reproduces.
522fn par_topk(
523    pool: Option<&crate::pool::Pool>,
524    src: &[f32],
525    k: usize,
526    out: &mut Vec<(u32, f32)>,
527    parts: &mut Vec<Vec<(u32, f32)>>,
528) {
529    let better = |a: (u32, f32), b: (u32, f32)| a.1 > b.1 || (a.1 == b.1 && a.0 < b.0);
530    // sorted insertion into a fixed k-slot list: the compare against the
531    // current k-th is what nearly every element pays, and nothing else.
532    let scan = |s: usize, e: usize, best: &mut Vec<(u32, f32)>| {
533        best.clear();
534        for i in s..e {
535            let c = (i as u32, src[i]);
536            if best.len() < k {
537                let pos = best
538                    .iter()
539                    .position(|&b| better(c, b))
540                    .unwrap_or(best.len());
541                best.insert(pos, c);
542            } else if better(c, best[k - 1]) {
543                let pos = best.iter().position(|&b| better(c, b)).unwrap_or(k - 1);
544                best.pop();
545                best.insert(pos, c);
546            }
547        }
548    };
549    let by_value_desc = |a: &(u32, f32), b: &(u32, f32)| {
550        b.1.partial_cmp(&a.1)
551            .unwrap_or(std::cmp::Ordering::Equal)
552            .then(a.0.cmp(&b.0))
553    };
554    // A degenerate row (thousands tied at the k-th place) is capped: the
555    // dense chain would keep them all; nobody samples such a row on
556    // purpose.
557    let cap = k * 4 + 64;
558    // gather everything ≥ kth into `slot`, at most `cap` entries
559    let gather = |s: usize, e: usize, kth: f32, slot: &mut Vec<(u32, f32)>| {
560        slot.clear();
561        for i in s..e {
562            let v = src[i];
563            if v >= kth {
564                slot.push((i as u32, v));
565                if slot.len() >= cap {
566                    break;
567                }
568            }
569        }
570    };
571    out.clear();
572    match pool {
573        Some(p) if src.len() >= PAR_MIN => {
574            let n = src.len();
575            let grain = crate::pool::grain_for(n, p.n_workers() + 1);
576            let ng = n.div_ceil(grain);
577            parts.resize_with(ng, Vec::new);
578            let pp = crate::pool::SendMutT::new(parts.as_mut_ptr());
579            p.run_rows(n, &|s, e| {
580                // SAFETY: grain g is written by exactly one range (start =
581                // g·grain) and `parts` outlives the joined dispatch.
582                let slot = unsafe { &mut *pp.at(s / grain) };
583                scan(s, e, slot);
584            });
585            for g in 0..ng {
586                out.extend_from_slice(&parts[g]);
587            }
588            out.sort_unstable_by(by_value_desc);
589            out.truncate(k);
590            let Some(&(_, kth)) = out.last() else {
591                return;
592            };
593            if !kth.is_finite() {
594                return; // -inf ties are the filtered-out set; keep the k
595            }
596            p.run_rows(n, &|s, e| {
597                let slot = unsafe { &mut *pp.at(s / grain) };
598                gather(s, e, kth, slot);
599            });
600            out.clear();
601            for g in 0..ng {
602                out.extend_from_slice(&parts[g]);
603                if out.len() >= cap {
604                    break;
605                }
606            }
607            out.sort_unstable_by(by_value_desc);
608            out.truncate(cap);
609        }
610        _ => {
611            scan(0, src.len(), out);
612            let Some(&(_, kth)) = out.last() else {
613                return;
614            };
615            if !kth.is_finite() {
616                return;
617            }
618            let mut all = std::mem::take(out);
619            gather(0, src.len(), kth, &mut all);
620            all.sort_unstable_by(by_value_desc);
621            all.truncate(cap);
622            *out = all;
623        }
624    }
625}
626
627/// Σ exp((l − lmax)/t) over the vocab, per-grain partials summed in
628/// grain order (deterministic across runs, so a seed reproduces).
629fn par_sum_exp(
630    pool: Option<&crate::pool::Pool>,
631    src: &[f32],
632    lmax: f32,
633    t: f32,
634    parts: &mut Vec<f32>,
635) -> f32 {
636    let term = |s: usize, e: usize| -> f32 {
637        let mut acc = 0.0f32;
638        for &l in &src[s..e] {
639            acc += ((l - lmax) / t).exp();
640        }
641        acc
642    };
643    match pool {
644        Some(p) if src.len() >= PAR_MIN => {
645            let n = src.len();
646            let grain = crate::pool::grain_for(n, p.n_workers() + 1);
647            let ng = n.div_ceil(grain);
648            parts.clear();
649            parts.resize(ng, 0.0);
650            let pp = crate::pool::SendMut::new(parts.as_mut_ptr());
651            p.run_rows(n, &|s, e| {
652                // SAFETY: one writer per grain slot; joined before read.
653                unsafe { *pp.at(s / grain) = term(s, e) };
654            });
655            parts.iter().sum()
656        }
657        _ => term(0, src.len()),
658    }
659}
660
661/// Draw from a sparse distribution: inverse CDF in id order — the same
662/// walk the dense `categorical_sample` makes over the vocab, so a seed
663/// lands on the same token when the survivor set and probs agree.
664pub fn draw_sparse(p: &[(u32, f32)], rng: &mut SplitMix64) -> u32 {
665    let r = rng.next_f32();
666    let mut cum = 0.0f32;
667    for &(id, pr) in p {
668        cum += pr;
669        if r < cum {
670            return id;
671        }
672    }
673    p.iter().rev().find(|c| c.1 > 0.0).map(|c| c.0).unwrap_or(0)
674}
675
676fn sparse_get(p: &[(u32, f32)], id: u32) -> f32 {
677    p.binary_search_by_key(&id, |c| c.0)
678        .map(|i| p[i].1)
679        .unwrap_or(0.0)
680}
681
682/// `spec_accept_or_correct` over sparse distributions: accept the draft
683/// `d` with min(1, p[d]/q[d]); on rejection draw the correction from the
684/// residual max(0, p − q) over p's support (q's support outside p
685/// contributes nothing to the residual). Empty residual → a draw from p.
686pub fn spec_accept_or_correct_sparse(
687    p: &[(u32, f32)],
688    q: &[(u32, f32)],
689    d: u32,
690    rng: &mut SplitMix64,
691    res: &mut Sparse,
692) -> Option<u32> {
693    let (pd, qd) = (sparse_get(p, d), sparse_get(q, d));
694    let r = rng.next_f32();
695    if qd > 0.0 && r * qd < pd {
696        return None;
697    }
698    res.clear();
699    let mut total = 0.0f32;
700    for &(id, pi) in p {
701        let ri = pi - sparse_get(q, id);
702        if ri > 0.0 {
703            res.push((id, ri));
704            total += ri;
705        }
706    }
707    if total <= 0.0 {
708        return Some(draw_sparse(p, rng));
709    }
710    for c in res.iter_mut() {
711        c.1 /= total;
712    }
713    Some(draw_sparse(res, rng))
714}
715
716/// The distribution the sampler would draw from — the whole chain minus
717/// the draw — as a normalized vector over the vocab, in `out`. Greedy
718/// configs (and the filtered-out fallback) come back as a one-hot, so a
719/// caller can treat every configuration uniformly. This is what
720/// speculative SAMPLING needs from both the draft head and the verify:
721/// accept-with-min(1, p/q), correct from max(0, p − q).
722pub fn distribution_into(
723    logits: &[f32],
724    config: &SamplerConfig,
725    past_tokens: &[u32],
726    scratch: &mut SamplerScratch,
727    pool: Option<&crate::pool::Pool>,
728    out: &mut Vec<f32>,
729) {
730    let one_hot = |out: &mut Vec<f32>, t: usize, n: usize| {
731        out.clear();
732        out.resize(n, 0.0);
733        if t < n {
734            out[t] = 1.0;
735        }
736    };
737    if config.temperature < 1e-6
738        && config.repetition_penalty == 1.0
739        && config.presence_penalty == 0.0
740        && config.suppress_tokens.is_empty()
741    {
742        return one_hot(out, argmax(logits) as usize, logits.len());
743    }
744    let mut probs = std::mem::take(&mut scratch.probs);
745    let normalized = chain(logits, config, past_tokens, scratch, pool, &mut probs);
746    if config.temperature < 1e-6 {
747        let t = argmax(&probs) as usize;
748        scratch.probs = probs;
749        return one_hot(out, t, logits.len());
750    }
751    if !normalized {
752        scratch.probs = probs;
753        return one_hot(out, argmax(logits) as usize, logits.len());
754    }
755    out.clear();
756    out.extend_from_slice(&probs);
757    scratch.probs = probs;
758}
759
760/// Draw from a normalized distribution with the caller's RNG.
761pub fn draw(probs: &[f32], rng: &mut SplitMix64) -> u32 {
762    categorical_sample(probs, rng.next_f32())
763}
764
765/// One step of speculative sampling (Leviathan et al. / Chen et al.):
766/// the draft `d` was drawn from `q`; the target distribution at the same
767/// position is `p`. Returns `None` when `d` is accepted (with probability
768/// min(1, p[d]/q[d])) and `Some(c)` when it is rejected, `c` drawn from
769/// the residual max(0, p − q) renormalized — which is exactly what makes
770/// the emitted token stream distributed as `p`, draft or no draft. When
771/// the residual is empty (p ⊆ q, so p == q on the support) the correction
772/// falls back to a draw from `p` itself. `scratch` holds the residual;
773/// the pool spreads the vocab-wide pass.
774pub fn spec_accept_or_correct(
775    p: &[f32],
776    q: &[f32],
777    d: u32,
778    rng: &mut SplitMix64,
779    scratch: &mut Vec<f32>,
780    pool: Option<&crate::pool::Pool>,
781) -> Option<u32> {
782    let di = d as usize;
783    let (pd, qd) = (
784        p.get(di).copied().unwrap_or(0.0),
785        q.get(di).copied().unwrap_or(0.0),
786    );
787    let r = rng.next_f32();
788    // accept iff r < min(1, pd/qd)  ⇔  r·qd < pd (qd > 0 since d was drawn from q)
789    if qd > 0.0 && r * qd < pd {
790        return None;
791    }
792    let n = p.len().min(q.len());
793    scratch.clear();
794    scratch.extend_from_slice(&p[..n]);
795    // residual = max(0, p − q), elementwise over the pool
796    {
797        let qp = q.as_ptr() as usize;
798        let sm = crate::pool::SendMut::new(scratch.as_mut_ptr());
799        let body = move |s: usize, e: usize| {
800            // SAFETY: disjoint ranges; q outlives the (joined) dispatch.
801            let qs = unsafe { std::slice::from_raw_parts(qp as *const f32, n) };
802            for i in s..e {
803                unsafe {
804                    let x = sm.at(i);
805                    *x = (*x - qs[i]).max(0.0);
806                }
807            }
808        };
809        match pool {
810            Some(pl) if n >= PAR_MIN => pl.run_rows(n, &body),
811            _ => body(0, n),
812        }
813    }
814    let sum: f32 = scratch.iter().sum();
815    if sum > 0.0 {
816        let inv = 1.0 / sum;
817        par_map(pool, scratch, &move |v| v * inv);
818        Some(categorical_sample(scratch, rng.next_f32()))
819    } else {
820        Some(categorical_sample(&p[..n], rng.next_f32()))
821    }
822}
823
824/// Greedy: index of the maximum value.
825///
826/// Four running maxima instead of one: the scalar `max_by` carried a loop
827/// dependency through the comparison, which at a 129k vocab is a tenth of a
828/// millisecond of pure serial work per token.
829///
830/// Ties resolve to the HIGHEST index — not an arbitrary choice, it is what
831/// `Iterator::max_by` does (it keeps the last of several equal maxima) and
832/// therefore what this has always returned. `explain`'s preview compares
833/// its own argmax against what greedy emits, and that test is what catches
834/// the flip.
835pub fn argmax(values: &[f32]) -> u32 {
836    if values.is_empty() {
837        return 0;
838    }
839    let n = values.len();
840    let mut best = [(0usize, f32::NEG_INFINITY); 4];
841    for (l, b) in best.iter_mut().enumerate() {
842        b.0 = l.min(n - 1);
843    }
844    let mut i = 0;
845    while i + 4 <= n {
846        for l in 0..4 {
847            let v = values[i + l];
848            if v >= best[l].1 {
849                best[l] = (i + l, v);
850            }
851        }
852        i += 4;
853    }
854    let mut bi = best[0].0;
855    let mut bv = best[0].1;
856    for b in &best[1..] {
857        if b.1 > bv || (b.1 == bv && b.0 > bi) {
858            bi = b.0;
859            bv = b.1;
860        }
861    }
862    while i < n {
863        if values[i] >= bv {
864            bv = values[i];
865            bi = i;
866        }
867        i += 1;
868    }
869    bi as u32
870}
871
872fn softmax_inplace(logits: &mut [f32]) {
873    let max_val = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
874    let mut sum = 0.0f32;
875    for v in logits.iter_mut() {
876        *v = (*v - max_val).exp();
877        sum += *v;
878    }
879    if sum > 0.0 {
880        for v in logits.iter_mut() {
881            *v /= sum;
882        }
883    }
884}
885
886/// Below this length the pool's dispatch costs more than the pass.
887const PAR_MIN: usize = 1 << 14;
888
889/// Elementwise `buf[i] = f(buf[i])` over the pool (serial without one, or
890/// for short buffers). Each output depends on its own input alone, so the
891/// chunking cannot change a single bit.
892fn par_map(pool: Option<&crate::pool::Pool>, buf: &mut [f32], f: &(dyn Fn(f32) -> f32 + Sync)) {
893    match pool {
894        Some(p) if buf.len() >= PAR_MIN => {
895            let out = crate::pool::SendMut::new(buf.as_mut_ptr());
896            p.run_rows(buf.len(), &move |s, e| {
897                for i in s..e {
898                    // SAFETY: ranges from run_rows are disjoint and the
899                    // buffer outlives the (joined) dispatch.
900                    unsafe {
901                        let q = out.at(i);
902                        *q = f(*q);
903                    }
904                }
905            });
906        }
907        _ => {
908            for v in buf.iter_mut() {
909                *v = f(*v);
910            }
911        }
912    }
913}
914
915/// `fold(init, f32::max)` over the pool. Max is order-free on non-NaN
916/// input, so per-chunk maxima combined give the serial fold's answer.
917fn par_max(pool: Option<&crate::pool::Pool>, buf: &[f32], init: f32) -> f32 {
918    match pool {
919        Some(p) if buf.len() >= PAR_MIN => {
920            let m = std::sync::Mutex::new(init);
921            p.run_rows(buf.len(), &|s, e| {
922                let local = buf[s..e].iter().cloned().fold(init, f32::max);
923                let mut g = m.lock().unwrap();
924                *g = g.max(local);
925            });
926            m.into_inner().unwrap()
927        }
928        _ => buf.iter().cloned().fold(init, f32::max),
929    }
930}
931
932/// `softmax_inplace` with the exp and the normalisation spread over the
933/// pool. The max is order-free, the exp is elementwise, and the SUM stays
934/// a sequential index-order fold — exactly the serial loop's accumulation
935/// — so the probabilities are bit-identical.
936fn softmax_inplace_pool(pool: Option<&crate::pool::Pool>, logits: &mut [f32]) {
937    if pool.is_none() || logits.len() < PAR_MIN {
938        return softmax_inplace(logits);
939    }
940    let max_val = par_max(pool, logits, f32::NEG_INFINITY);
941    par_map(pool, logits, &move |v| (v - max_val).exp());
942    let sum: f32 = logits.iter().sum();
943    if sum > 0.0 {
944        par_map(pool, logits, &move |v| v / sum);
945    }
946}
947
948/// The k-th largest value of `probs` (k ≥ 1, k ≤ len), by the same
949/// descending `partial_cmp` order `select_nth_unstable_by` used — one
950/// streaming pass with a k-slot min-heap instead of a whole-vocab copy
951/// and partition. Values, not indices, so ties give the same threshold.
952fn kth_largest(probs: &[f32], k: usize) -> f32 {
953    use std::cmp::Ordering;
954    // Min-heap on the k largest seen so far: `heap[0]` is the smallest of
955    // them, i.e. the running k-th largest.
956    let mut heap: Vec<f32> = Vec::with_capacity(k);
957    let desc = |a: f32, b: f32| b.partial_cmp(&a).unwrap_or(Ordering::Equal);
958    let sift_down = |h: &mut [f32], mut i: usize| {
959        let n = h.len();
960        loop {
961            let (l, r) = (2 * i + 1, 2 * i + 2);
962            let mut m = i;
963            // child "smaller" in the descending order = later in it
964            if l < n && desc(h[l], h[m]) == Ordering::Greater {
965                m = l;
966            }
967            if r < n && desc(h[r], h[m]) == Ordering::Greater {
968                m = r;
969            }
970            if m == i {
971                break;
972            }
973            h.swap(i, m);
974            i = m;
975        }
976    };
977    let sift_up = |h: &mut [f32], mut i: usize| {
978        while i > 0 {
979            let parent = (i - 1) / 2;
980            if desc(h[i], h[parent]) == Ordering::Greater {
981                h.swap(i, parent);
982                i = parent;
983            } else {
984                break;
985            }
986        }
987    };
988    for &v in probs {
989        if heap.len() < k {
990            heap.push(v);
991            let n = heap.len();
992            sift_up(&mut heap, n - 1);
993        } else if desc(v, heap[0]) == Ordering::Less {
994            // v is larger than the current k-th largest: replace it.
995            heap[0] = v;
996            sift_down(&mut heap, 0);
997        }
998    }
999    heap[0]
1000}
1001
1002/// `apply_top_k` without the second vocab-sized copy: the threshold is
1003/// the k-th largest value from one streaming pass, the zeroing is an
1004/// elementwise pass over the pool. Same kept set, same values.
1005fn apply_top_k_pool(pool: Option<&crate::pool::Pool>, probs: &mut [f32], k: usize) {
1006    if k == 0 || k >= probs.len() {
1007        return;
1008    }
1009    let threshold = kth_largest(probs, k);
1010    par_map(pool, probs, &move |p| if p < threshold { 0.0 } else { p });
1011}
1012
1013/// Top-1 probability of `id` under a softmax at temperature `temp` — the
1014/// per-token confidence — with the exp pass over the pool and the sum
1015/// sequential in index order (bit-identical to the serial fold). Uses
1016/// the scratch's partition buffer, idle now that top-k streams.
1017pub fn top1_prob_pool(
1018    pool: Option<&crate::pool::Pool>,
1019    scratch: &mut SamplerScratch,
1020    logits: &[f32],
1021    id: u32,
1022    temp: f32,
1023) -> f32 {
1024    let t = if temp > 1e-3 { temp } else { 1.0 };
1025    let max = par_max(pool, logits, f32::NEG_INFINITY);
1026    let mut e = std::mem::take(&mut scratch.topk);
1027    e.clear();
1028    e.extend_from_slice(logits);
1029    par_map(pool, &mut e, &move |v| ((v - max) / t).exp());
1030    let sum: f32 = e.iter().sum();
1031    let out = if sum > 0.0 {
1032        (((logits[id as usize] - max) / t).exp()) / sum
1033    } else {
1034        0.0
1035    };
1036    scratch.topk = e;
1037    out
1038}
1039
1040fn apply_repetition_penalty(
1041    logits: &mut [f32],
1042    past_tokens: &[u32],
1043    penalty: f32,
1044    scratch: &mut SamplerScratch,
1045) {
1046    let epoch = scratch.begin_seen(logits.len());
1047    for &tok in past_tokens {
1048        let idx = tok as usize;
1049        if idx < logits.len() && scratch.seen_epoch[idx] != epoch {
1050            scratch.seen_epoch[idx] = epoch;
1051            if logits[idx] > 0.0 {
1052                logits[idx] /= penalty;
1053            } else {
1054                logits[idx] *= penalty;
1055            }
1056        }
1057    }
1058}
1059
1060/// Keep the k highest-probability tokens (plus exact ties at the
1061/// threshold), zero the rest. Selection, not a full vocab sort — the
1062/// old double `sort_by` over ~150k probs was ~1ms of pure per-token
1063/// overhead (roadmap §3 P0).
1064fn apply_top_k(probs: &mut [f32], k: usize, sel: &mut Vec<f32>) {
1065    if k == 0 || k >= probs.len() {
1066        return;
1067    }
1068    // `select_nth_unstable` permutes, so the partition needs its own
1069    // buffer — but not a FRESH one each token.
1070    sel.clear();
1071    sel.extend_from_slice(probs);
1072    // k-th largest = (k-1)-th index in a descending partition.
1073    let (_, kth, _) = sel.select_nth_unstable_by(k - 1, |a, b| {
1074        b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
1075    });
1076    let threshold = *kth;
1077    for p in probs.iter_mut() {
1078        if *p < threshold {
1079            *p = 0.0;
1080        }
1081    }
1082}
1083
1084/// Nucleus: keep the smallest prefix of tokens whose cumulative
1085/// probability reaches top_p. Only surviving (non-zero) candidates are
1086/// sorted — after top-k that is ≤ k elements, not the whole vocab; the
1087/// kept set is marked in-place instead of a per-token HashSet.
1088fn apply_top_p(probs: &mut [f32], top_p: f32) {
1089    let mut indexed: Vec<(usize, f32)> = probs
1090        .iter()
1091        .copied()
1092        .enumerate()
1093        .filter(|&(_, p)| p > 0.0)
1094        .collect();
1095    indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1096
1097    let mut cumsum = 0.0f32;
1098    let mut cutoff_idx = indexed.len();
1099    for (i, &(_, prob)) in indexed.iter().enumerate() {
1100        cumsum += prob;
1101        if cumsum >= top_p {
1102            cutoff_idx = i + 1;
1103            break;
1104        }
1105    }
1106
1107    // Zero the dropped tail directly — indices, not membership tests.
1108    for &(i, _) in &indexed[cutoff_idx..] {
1109        probs[i] = 0.0;
1110    }
1111}
1112
1113/// Inverse-CDF sampling with an externally supplied uniform r ∈ [0, 1).
1114fn categorical_sample(probs: &[f32], r: f32) -> u32 {
1115    let mut cumsum = 0.0f32;
1116    for (i, &p) in probs.iter().enumerate() {
1117        cumsum += p;
1118        if r < cumsum {
1119            return i as u32;
1120        }
1121    }
1122    probs.iter().rposition(|&p| p > 0.0).unwrap_or(0) as u32
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127    /// The four-lane argmax against the obvious scalar one, including the
1128    /// tie rule: `max_by` keeps the LAST of several equal maxima, and a
1129    /// lane split that quietly picked the first would move greedy output on
1130    /// any model with two equally-likely tokens.
1131    #[test]
1132    fn argmax_lanes_match_the_scalar_one_ties_and_all() {
1133        // The reference IS the old implementation, `max_by` and all — the
1134        // point is that nothing observable changed, tie rule included.
1135        let scalar = |v: &[f32]| -> u32 {
1136            v.iter()
1137                .enumerate()
1138                .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1139                .map(|(i, _)| i as u32)
1140                .unwrap_or(0)
1141        };
1142        for n in 0..40usize {
1143            for seed in 0..8u64 {
1144                let mut r = super::SplitMix64::new(seed * 7 + n as u64);
1145                // Quantized to few distinct values on purpose: ties are the
1146                // case the lanes can get wrong and random floats never hit.
1147                let v: Vec<f32> = (0..n).map(|_| ((r.next_u64() % 5) as f32) - 2.0).collect();
1148                assert_eq!(super::argmax(&v), scalar(&v), "n={n} seed={seed} {v:?}");
1149            }
1150        }
1151        let flat = vec![f32::NEG_INFINITY; 13];
1152        assert_eq!(super::argmax(&flat), scalar(&flat));
1153    }
1154
1155    use super::*;
1156
1157    #[test]
1158    fn test_argmax() {
1159        let logits = vec![0.1, 0.5, 0.3, 0.9, 0.2];
1160        assert_eq!(argmax(&logits), 3);
1161    }
1162
1163    #[test]
1164    fn test_greedy_sampling() {
1165        let logits = vec![1.0, 5.0, 2.0, 3.0];
1166        let config = SamplerConfig {
1167            temperature: 0.0,
1168            ..Default::default()
1169        };
1170        let mut rng = SplitMix64::new(1);
1171        assert_eq!(sample(&logits, &config, &[], &mut rng), 1);
1172    }
1173
1174    /// `argmax_penalized` must equal the copy-and-penalize chain's argmax
1175    /// — same values, same tie rule — with and without the pool.
1176    #[test]
1177    fn argmax_penalized_matches_chain_argmax() {
1178        let pool = crate::pool::Pool::new(3);
1179        let n = 40_000usize;
1180        for seed in 0..8u64 {
1181            let mut r = SplitMix64::new(seed + 3);
1182            // coarse values so ties happen
1183            let logits: Vec<f32> = (0..n)
1184                .map(|_| ((r.next_u64() % 41) as f32 - 20.0) / 4.0)
1185                .collect();
1186            let past: Vec<u32> = (0..2000)
1187                .map(|_| (r.next_u64() % n as u64) as u32)
1188                .collect();
1189            for cfg in [
1190                SamplerConfig {
1191                    temperature: 0.0,
1192                    repetition_penalty: 1.1,
1193                    ..Default::default()
1194                },
1195                SamplerConfig {
1196                    temperature: 0.0,
1197                    repetition_penalty: 1.0,
1198                    presence_penalty: 1.5,
1199                    ..Default::default()
1200                },
1201                SamplerConfig {
1202                    temperature: 0.0,
1203                    repetition_penalty: 1.3,
1204                    presence_penalty: 0.7,
1205                    suppress_tokens: vec![5, 77, 3000],
1206                    ..Default::default()
1207                },
1208            ] {
1209                let mut s1 = SamplerScratch::default();
1210                let mut probs = Vec::new();
1211                chain(&logits, &cfg, &past, &mut s1, None, &mut probs);
1212                let want = argmax(&probs);
1213                let mut s2 = SamplerScratch::default();
1214                let got_serial = argmax_penalized(&logits, &cfg, &past, &mut s2, None);
1215                let got_pool = argmax_penalized(&logits, &cfg, &past, &mut s2, Some(&pool));
1216                assert_eq!(want, got_serial, "serial, seed {seed} cfg {cfg:?}");
1217                assert_eq!(want, got_pool, "pool, seed {seed} cfg {cfg:?}");
1218            }
1219        }
1220    }
1221
1222    /// The pool chain must sample the SAME token as the serial one on the
1223    /// same seed — that is the whole contract of the parallel passes.
1224    #[test]
1225    fn pool_chain_matches_serial_bit_for_bit() {
1226        let pool = crate::pool::Pool::new(3);
1227        let n = 40_000usize; // above PAR_MIN so the pool arm is exercised
1228        for seed in 0..6u64 {
1229            let mut r = SplitMix64::new(seed + 11);
1230            let logits: Vec<f32> = (0..n)
1231                .map(|i| ((r.next_u64() % 2001) as f32 - 1000.0) / 90.0 + (i % 7) as f32 * 0.01)
1232                .collect();
1233            let past: Vec<u32> = (0..500).map(|_| (r.next_u64() % n as u64) as u32).collect();
1234            for cfg in [
1235                SamplerConfig::default(),
1236                SamplerConfig {
1237                    temperature: 0.7,
1238                    top_p: 0.8,
1239                    top_k: 20,
1240                    min_p: 0.0,
1241                    presence_penalty: 1.5,
1242                    repetition_penalty: 1.0,
1243                    ..Default::default()
1244                },
1245                SamplerConfig {
1246                    temperature: 1.0,
1247                    top_p: 0.95,
1248                    top_k: 20,
1249                    min_p: 0.0,
1250                    ..Default::default()
1251                },
1252                SamplerConfig {
1253                    temperature: 1.3,
1254                    top_p: 1.0,
1255                    top_k: 0,
1256                    min_p: 0.02,
1257                    ..Default::default()
1258                },
1259            ] {
1260                let mut s1 = SamplerScratch::default();
1261                let mut s2 = SamplerScratch::default();
1262                for step in 0..5u64 {
1263                    let mut r1 = SplitMix64::new(seed * 100 + step);
1264                    let mut r2 = r1.clone();
1265                    let a = sample_with_scratch(&logits, &cfg, &past, &mut r1, &mut s1);
1266                    let b = sample_with_scratch_pool(
1267                        &logits,
1268                        &cfg,
1269                        &past,
1270                        &mut r2,
1271                        &mut s2,
1272                        Some(&pool),
1273                    );
1274                    assert_eq!(a, b, "seed {seed} step {step} cfg {cfg:?}");
1275                    // and the working copies agree value for value
1276                    assert_eq!(s1.probs, s2.probs, "probs differ seed {seed} step {step}");
1277                }
1278            }
1279        }
1280    }
1281
1282    /// Speculative sampling must reproduce the TARGET distribution however
1283    /// good or bad the draft is: draw d ~ q, accept with min(1, p/q), else
1284    /// correct from max(0, p − q). Empirical law over many trials against
1285    /// p itself, for a sharp draft, a flat draft and a wrong draft.
1286    #[test]
1287    fn spec_accept_or_correct_reproduces_the_target() {
1288        let n = 40usize;
1289        let mk = |seed: u64, sharp: f32| -> Vec<f32> {
1290            let mut r = SplitMix64::new(seed);
1291            let mut v: Vec<f32> = (0..n)
1292                .map(|_| ((r.next_u64() % 1000) as f32 / 1000.0).powf(sharp))
1293                .collect();
1294            // a few exact zeros, like a top-k'd distribution
1295            for i in 0..n {
1296                if (i * 7 + seed as usize) % 5 == 0 {
1297                    v[i] = 0.0;
1298                }
1299            }
1300            let s: f32 = v.iter().sum();
1301            v.iter().map(|x| x / s).collect()
1302        };
1303        let p = mk(3, 3.0);
1304        for (qi, q) in [mk(3, 3.0), mk(11, 1.0), mk(29, 6.0)]
1305            .into_iter()
1306            .enumerate()
1307        {
1308            let mut rng = SplitMix64::new(77 + qi as u64);
1309            let mut counts = vec![0u64; n];
1310            let mut scratch = Vec::new();
1311            let trials = 400_000u64;
1312            for _ in 0..trials {
1313                let d = categorical_sample(&q, rng.next_f32());
1314                let t = match spec_accept_or_correct(&p, &q, d, &mut rng, &mut scratch, None) {
1315                    None => d,
1316                    Some(c) => c,
1317                };
1318                counts[t as usize] += 1;
1319            }
1320            let l1: f64 = (0..n)
1321                .map(|i| (counts[i] as f64 / trials as f64 - p[i] as f64).abs())
1322                .sum();
1323            eprintln!("spec q#{qi}: L1(empirical, p) = {l1:.4}");
1324            assert!(
1325                l1 < 0.01,
1326                "q#{qi}: empirical distribution drifted from p, L1 {l1}"
1327            );
1328            // and nothing outside p's support was ever emitted
1329            for i in 0..n {
1330                if p[i] == 0.0 {
1331                    assert_eq!(counts[i], 0, "q#{qi}: token {i} outside p emitted");
1332                }
1333            }
1334        }
1335    }
1336
1337    /// The sparse chain is the dense chain: same survivor set, same
1338    /// probabilities (to fp), serial and pooled, with and without
1339    /// penalties, min-p, top-p — and a seed draws the same token.
1340    #[test]
1341    fn sparse_chain_matches_the_dense_chain() {
1342        let pool = crate::pool::Pool::new(3);
1343        let n = 40_000usize; // above PAR_MIN: the pooled arms run
1344        for seed in 0..5u64 {
1345            let mut r = SplitMix64::new(100 + seed);
1346            let logits: Vec<f32> = (0..n)
1347                .map(|_| ((r.next_u64() % 3000) as f32 - 1500.0) / 120.0)
1348                .collect();
1349            let past: Vec<u32> = (0..400).map(|_| (r.next_u64() % n as u64) as u32).collect();
1350            for cfg in [
1351                SamplerConfig {
1352                    temperature: 0.7,
1353                    top_p: 0.8,
1354                    top_k: 20,
1355                    min_p: 0.0,
1356                    presence_penalty: 1.5,
1357                    repetition_penalty: 1.0,
1358                    ..Default::default()
1359                },
1360                SamplerConfig {
1361                    temperature: 1.0,
1362                    top_p: 0.95,
1363                    top_k: 40,
1364                    min_p: 0.05,
1365                    presence_penalty: 0.0,
1366                    repetition_penalty: 1.1,
1367                    ..Default::default()
1368                },
1369                SamplerConfig {
1370                    temperature: 0.6,
1371                    top_p: 1.0,
1372                    top_k: 3,
1373                    min_p: 0.0,
1374                    presence_penalty: 0.0,
1375                    repetition_penalty: 1.0,
1376                    suppress_tokens: vec![5, 6, 7],
1377                    ..Default::default()
1378                },
1379            ] {
1380                assert!(sparse_ok(&cfg));
1381                let mut sd = SamplerScratch::default();
1382                let mut dense = Vec::new();
1383                distribution_into(&logits, &cfg, &past, &mut sd, None, &mut dense);
1384                for pl in [None, Some(&pool)] {
1385                    let mut ss = SamplerScratch::default();
1386                    let mut sp = Vec::new();
1387                    let ok = sparse_distribution_into(&logits, &cfg, &past, &mut ss, pl, &mut sp);
1388                    assert!(ok, "seed {seed} cfg {cfg:?}");
1389                    let dense_nz: Vec<(u32, f32)> = dense
1390                        .iter()
1391                        .enumerate()
1392                        .filter(|&(_, &v)| v > 0.0)
1393                        .map(|(i, &v)| (i as u32, v))
1394                        .collect();
1395                    assert_eq!(
1396                        dense_nz.len(),
1397                        sp.len(),
1398                        "seed {seed} pool {} cfg {cfg:?}: support {:?} vs {:?}",
1399                        pl.is_some(),
1400                        dense_nz,
1401                        sp
1402                    );
1403                    for (a, b) in dense_nz.iter().zip(sp.iter()) {
1404                        assert_eq!(a.0, b.0, "seed {seed} cfg {cfg:?}: ids differ");
1405                        assert!(
1406                            (a.1 - b.1).abs() <= 2e-5 * a.1.max(1e-3),
1407                            "seed {seed} cfg {cfg:?}: prob {} vs {}",
1408                            a.1,
1409                            b.1
1410                        );
1411                    }
1412                    // the seed lands on the same token (both walk the ids
1413                    // in order); allow a boundary rounding miss or two
1414                    let mut agree = 0usize;
1415                    let trials = 400usize;
1416                    for k in 0..trials as u64 {
1417                        let mut r1 = SplitMix64::new(500 + k);
1418                        let mut r2 = SplitMix64::new(500 + k);
1419                        let a = categorical_sample(&dense, r1.next_f32());
1420                        let b = draw_sparse(&sp, &mut r2);
1421                        agree += (a == b) as usize;
1422                    }
1423                    assert!(
1424                        agree >= trials - 2,
1425                        "seed {seed} cfg {cfg:?}: agree {agree}/{trials}"
1426                    );
1427                    // and the public entry uses it
1428                    let mut r1 = SplitMix64::new(9);
1429                    let mut r2 = SplitMix64::new(9);
1430                    let a = categorical_sample(&dense, r1.next_f32());
1431                    let mut s3 = SamplerScratch::default();
1432                    let b = sample_with_scratch_pool(&logits, &cfg, &past, &mut r2, &mut s3, pl);
1433                    assert_eq!(a, b, "seed {seed} cfg {cfg:?}: entry draw");
1434                }
1435            }
1436        }
1437    }
1438
1439    /// The sparse accept/correct emits the target distribution, like its
1440    /// dense twin — the same 400k-trial law test over sparse p and q.
1441    #[test]
1442    fn spec_accept_or_correct_sparse_reproduces_the_target() {
1443        let n = 40usize;
1444        let mk = |seed: u64, sharp: f32| -> Vec<(u32, f32)> {
1445            let mut r = SplitMix64::new(seed);
1446            let mut v: Vec<f32> = (0..n)
1447                .map(|_| ((r.next_u64() % 1000) as f32 / 1000.0).powf(sharp))
1448                .collect();
1449            for i in 0..n {
1450                if (i * 7 + seed as usize) % 5 == 0 {
1451                    v[i] = 0.0;
1452                }
1453            }
1454            let s: f32 = v.iter().sum();
1455            v.iter()
1456                .enumerate()
1457                .filter(|&(_, &x)| x > 0.0)
1458                .map(|(i, &x)| (i as u32, x / s))
1459                .collect()
1460        };
1461        let p = mk(3, 3.0);
1462        for (qi, q) in [mk(3, 3.0), mk(11, 1.0), mk(29, 6.0)]
1463            .into_iter()
1464            .enumerate()
1465        {
1466            let mut rng = SplitMix64::new(77 + qi as u64);
1467            let mut counts = vec![0u64; n];
1468            let mut res = Vec::new();
1469            let trials = 400_000u64;
1470            for _ in 0..trials {
1471                let d = draw_sparse(&q, &mut rng);
1472                let t = match spec_accept_or_correct_sparse(&p, &q, d, &mut rng, &mut res) {
1473                    None => d,
1474                    Some(c) => c,
1475                };
1476                counts[t as usize] += 1;
1477            }
1478            let l1: f64 = (0..n)
1479                .map(|i| (counts[i] as f64 / trials as f64 - sparse_get(&p, i as u32) as f64).abs())
1480                .sum();
1481            eprintln!("sparse spec q#{qi}: L1(empirical, p) = {l1:.4}");
1482            assert!(l1 < 0.01, "q#{qi}: drifted, L1 {l1}");
1483            for i in 0..n {
1484                if sparse_get(&p, i as u32) == 0.0 {
1485                    assert_eq!(counts[i], 0, "q#{qi}: token {i} outside p emitted");
1486                }
1487            }
1488        }
1489    }
1490
1491    /// `distribution_into` is the sampler's own chain: drawing from it
1492    /// with the same uniform lands on the same token as `sample_*`.
1493    #[test]
1494    fn distribution_matches_the_sampler_draw() {
1495        let n = 20_000usize;
1496        let mut r = SplitMix64::new(9);
1497        let logits: Vec<f32> = (0..n)
1498            .map(|_| ((r.next_u64() % 3000) as f32 - 1500.0) / 120.0)
1499            .collect();
1500        let past: Vec<u32> = (0..300).map(|_| (r.next_u64() % n as u64) as u32).collect();
1501        for cfg in [
1502            SamplerConfig::default(),
1503            SamplerConfig {
1504                temperature: 0.7,
1505                top_p: 0.8,
1506                top_k: 20,
1507                min_p: 0.0,
1508                presence_penalty: 1.5,
1509                repetition_penalty: 1.0,
1510                ..Default::default()
1511            },
1512            SamplerConfig {
1513                temperature: 0.0,
1514                repetition_penalty: 1.1,
1515                ..Default::default()
1516            },
1517            SamplerConfig {
1518                temperature: 0.0,
1519                repetition_penalty: 1.0,
1520                presence_penalty: 0.0,
1521                ..Default::default()
1522            },
1523        ] {
1524            let mut s1 = SamplerScratch::default();
1525            let mut s2 = SamplerScratch::default();
1526            for step in 0..4u64 {
1527                let mut r1 = SplitMix64::new(step + 1);
1528                let mut r2 = r1.clone();
1529                let a = sample_with_scratch(&logits, &cfg, &past, &mut r1, &mut s1);
1530                let mut dist = Vec::new();
1531                distribution_into(&logits, &cfg, &past, &mut s2, None, &mut dist);
1532                assert!(
1533                    (dist.iter().sum::<f32>() - 1.0).abs() < 1e-3,
1534                    "not normalized: {}",
1535                    dist.iter().sum::<f32>()
1536                );
1537                let b = if cfg.temperature < 1e-6 {
1538                    argmax(&dist)
1539                } else {
1540                    draw(&dist, &mut r2)
1541                };
1542                assert_eq!(a, b, "cfg {cfg:?} step {step}");
1543            }
1544        }
1545    }
1546
1547    /// The streaming k-th largest against `select_nth_unstable_by` — the
1548    /// value the old partition returned, ties and zeros included.
1549    #[test]
1550    fn kth_largest_equals_select_nth() {
1551        for seed in 0..20u64 {
1552            let mut r = SplitMix64::new(seed);
1553            let n = 50 + (r.next_u64() % 3000) as usize;
1554            let v: Vec<f32> = (0..n)
1555                .map(|_| {
1556                    if r.next_u64() % 3 == 0 {
1557                        0.0
1558                    } else {
1559                        (r.next_u64() % 97) as f32 / 97.0
1560                    }
1561                })
1562                .collect();
1563            for k in [1usize, 2, 5, 20, 40, n / 2, n - 1] {
1564                let mut sel = v.clone();
1565                let (_, kth, _) = sel.select_nth_unstable_by(k - 1, |a, b| {
1566                    b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
1567                });
1568                assert_eq!(kth_largest(&v, k), *kth, "seed {seed} k {k}");
1569            }
1570        }
1571    }
1572
1573    /// The pooled confidence equals the serial formula, bit for bit.
1574    #[test]
1575    fn top1_prob_pool_matches_serial() {
1576        let pool = crate::pool::Pool::new(2);
1577        let n = 30_000usize;
1578        let mut r = SplitMix64::new(5);
1579        let logits: Vec<f32> = (0..n)
1580            .map(|_| ((r.next_u64() % 1000) as f32) / 37.0)
1581            .collect();
1582        let serial = |id: u32, temp: f32| -> f32 {
1583            let t = if temp > 1e-3 { temp } else { 1.0 };
1584            let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
1585            let sum: f32 = logits.iter().map(|&v| ((v - max) / t).exp()).sum();
1586            (((logits[id as usize] - max) / t).exp()) / sum
1587        };
1588        let mut sc = SamplerScratch::default();
1589        for (id, t) in [(3u32, 1.0f32), (777, 0.7), (29_999, 2.0), (12, 0.0)] {
1590            let a = serial(id, t);
1591            let b = top1_prob_pool(Some(&pool), &mut sc, &logits, id, t);
1592            assert_eq!(a.to_bits(), b.to_bits(), "id {id} t {t}: {a} vs {b}");
1593        }
1594    }
1595
1596    #[test]
1597    fn test_softmax() {
1598        let mut logits = vec![1.0, 2.0, 3.0];
1599        softmax_inplace(&mut logits);
1600        let sum: f32 = logits.iter().sum();
1601        assert!((sum - 1.0).abs() < 1e-5);
1602        assert!(logits[2] > logits[1] && logits[1] > logits[0]);
1603    }
1604
1605    #[test]
1606    fn test_repetition_penalty() {
1607        let mut logits = vec![1.0, 2.0, 3.0, 4.0];
1608        let mut scratch = SamplerScratch::default();
1609        apply_repetition_penalty(&mut logits, &[1, 3], 2.0, &mut scratch);
1610        assert_eq!(logits, vec![1.0, 1.0, 3.0, 2.0]);
1611    }
1612
1613    #[test]
1614    fn repetition_penalty_applies_once_per_unique_token() {
1615        let mut logits = vec![1.0, 4.0, -6.0];
1616        let mut scratch = SamplerScratch::default();
1617        apply_repetition_penalty(&mut logits, &[1, 1, 2, 1, 2], 2.0, &mut scratch);
1618        assert_eq!(logits, vec![1.0, 2.0, -12.0]);
1619    }
1620
1621    #[test]
1622    fn top_k_keeps_exactly_k() {
1623        let mut probs = vec![0.1, 0.4, 0.05, 0.3, 0.15];
1624        apply_top_k(&mut probs, 2, &mut Vec::new());
1625        let kept = probs.iter().filter(|&&p| p > 0.0).count();
1626        assert_eq!(kept, 2, "top-k must keep exactly k (was k+1 in v1)");
1627        assert!(probs[1] > 0.0 && probs[3] > 0.0);
1628    }
1629
1630    #[test]
1631    fn rng_reaches_full_cdf() {
1632        // v1 bug: r < 0.233 always, so the CDF tail was unreachable.
1633        // With uniform probs the LAST index must be sampled sometimes.
1634        let probs = vec![0.25f32; 4];
1635        let mut rng = SplitMix64::new(42);
1636        let mut hits = [0usize; 4];
1637        for _ in 0..4000 {
1638            let i = categorical_sample(&probs, rng.next_f32()) as usize;
1639            hits[i] += 1;
1640        }
1641        for (i, &h) in hits.iter().enumerate() {
1642            assert!(h > 700, "index {i} sampled only {h}/4000 — biased RNG");
1643        }
1644    }
1645
1646    #[test]
1647    fn same_seed_same_sequence() {
1648        let logits: Vec<f32> = (0..32).map(|i| (i as f32 * 0.37).sin()).collect();
1649        let config = SamplerConfig {
1650            temperature: 1.0,
1651            seed: Some(7),
1652            ..Default::default()
1653        };
1654        let run = |seed: u64| -> Vec<u32> {
1655            let mut rng = SplitMix64::new(seed);
1656            (0..16)
1657                .map(|_| sample(&logits, &config, &[], &mut rng))
1658                .collect()
1659        };
1660        assert_eq!(run(7), run(7), "same seed must reproduce");
1661        assert_ne!(run(7), run(8), "different seed must differ");
1662    }
1663}