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.iter().position(|&b| better(c, b)).unwrap_or(best.len());
538                best.insert(pos, c);
539            } else if better(c, best[k - 1]) {
540                let pos = best.iter().position(|&b| better(c, b)).unwrap_or(k - 1);
541                best.pop();
542                best.insert(pos, c);
543            }
544        }
545    };
546    let by_value_desc = |a: &(u32, f32), b: &(u32, f32)| {
547        b.1.partial_cmp(&a.1)
548            .unwrap_or(std::cmp::Ordering::Equal)
549            .then(a.0.cmp(&b.0))
550    };
551    // A degenerate row (thousands tied at the k-th place) is capped: the
552    // dense chain would keep them all; nobody samples such a row on
553    // purpose.
554    let cap = k * 4 + 64;
555    // gather everything ≥ kth into `slot`, at most `cap` entries
556    let gather = |s: usize, e: usize, kth: f32, slot: &mut Vec<(u32, f32)>| {
557        slot.clear();
558        for i in s..e {
559            let v = src[i];
560            if v >= kth {
561                slot.push((i as u32, v));
562                if slot.len() >= cap {
563                    break;
564                }
565            }
566        }
567    };
568    out.clear();
569    match pool {
570        Some(p) if src.len() >= PAR_MIN => {
571            let n = src.len();
572            let grain = crate::pool::grain_for(n, p.n_workers() + 1);
573            let ng = n.div_ceil(grain);
574            parts.resize_with(ng, Vec::new);
575            let pp = crate::pool::SendMutT::new(parts.as_mut_ptr());
576            p.run_rows(n, &|s, e| {
577                // SAFETY: grain g is written by exactly one range (start =
578                // g·grain) and `parts` outlives the joined dispatch.
579                let slot = unsafe { &mut *pp.at(s / grain) };
580                scan(s, e, slot);
581            });
582            for g in 0..ng {
583                out.extend_from_slice(&parts[g]);
584            }
585            out.sort_unstable_by(by_value_desc);
586            out.truncate(k);
587            let Some(&(_, kth)) = out.last() else {
588                return;
589            };
590            if !kth.is_finite() {
591                return; // -inf ties are the filtered-out set; keep the k
592            }
593            p.run_rows(n, &|s, e| {
594                let slot = unsafe { &mut *pp.at(s / grain) };
595                gather(s, e, kth, slot);
596            });
597            out.clear();
598            for g in 0..ng {
599                out.extend_from_slice(&parts[g]);
600                if out.len() >= cap {
601                    break;
602                }
603            }
604            out.sort_unstable_by(by_value_desc);
605            out.truncate(cap);
606        }
607        _ => {
608            scan(0, src.len(), out);
609            let Some(&(_, kth)) = out.last() else {
610                return;
611            };
612            if !kth.is_finite() {
613                return;
614            }
615            let mut all = std::mem::take(out);
616            gather(0, src.len(), kth, &mut all);
617            all.sort_unstable_by(by_value_desc);
618            all.truncate(cap);
619            *out = all;
620        }
621    }
622}
623
624/// Σ exp((l − lmax)/t) over the vocab, per-grain partials summed in
625/// grain order (deterministic across runs, so a seed reproduces).
626fn par_sum_exp(
627    pool: Option<&crate::pool::Pool>,
628    src: &[f32],
629    lmax: f32,
630    t: f32,
631    parts: &mut Vec<f32>,
632) -> f32 {
633    let term = |s: usize, e: usize| -> f32 {
634        let mut acc = 0.0f32;
635        for &l in &src[s..e] {
636            acc += ((l - lmax) / t).exp();
637        }
638        acc
639    };
640    match pool {
641        Some(p) if src.len() >= PAR_MIN => {
642            let n = src.len();
643            let grain = crate::pool::grain_for(n, p.n_workers() + 1);
644            let ng = n.div_ceil(grain);
645            parts.clear();
646            parts.resize(ng, 0.0);
647            let pp = crate::pool::SendMut::new(parts.as_mut_ptr());
648            p.run_rows(n, &|s, e| {
649                // SAFETY: one writer per grain slot; joined before read.
650                unsafe { *pp.at(s / grain) = term(s, e) };
651            });
652            parts.iter().sum()
653        }
654        _ => term(0, src.len()),
655    }
656}
657
658/// Draw from a sparse distribution: inverse CDF in id order — the same
659/// walk the dense `categorical_sample` makes over the vocab, so a seed
660/// lands on the same token when the survivor set and probs agree.
661pub fn draw_sparse(p: &[(u32, f32)], rng: &mut SplitMix64) -> u32 {
662    let r = rng.next_f32();
663    let mut cum = 0.0f32;
664    for &(id, pr) in p {
665        cum += pr;
666        if r < cum {
667            return id;
668        }
669    }
670    p.iter().rev().find(|c| c.1 > 0.0).map(|c| c.0).unwrap_or(0)
671}
672
673fn sparse_get(p: &[(u32, f32)], id: u32) -> f32 {
674    p.binary_search_by_key(&id, |c| c.0)
675        .map(|i| p[i].1)
676        .unwrap_or(0.0)
677}
678
679/// `spec_accept_or_correct` over sparse distributions: accept the draft
680/// `d` with min(1, p[d]/q[d]); on rejection draw the correction from the
681/// residual max(0, p − q) over p's support (q's support outside p
682/// contributes nothing to the residual). Empty residual → a draw from p.
683pub fn spec_accept_or_correct_sparse(
684    p: &[(u32, f32)],
685    q: &[(u32, f32)],
686    d: u32,
687    rng: &mut SplitMix64,
688    res: &mut Sparse,
689) -> Option<u32> {
690    let (pd, qd) = (sparse_get(p, d), sparse_get(q, d));
691    let r = rng.next_f32();
692    if qd > 0.0 && r * qd < pd {
693        return None;
694    }
695    res.clear();
696    let mut total = 0.0f32;
697    for &(id, pi) in p {
698        let ri = pi - sparse_get(q, id);
699        if ri > 0.0 {
700            res.push((id, ri));
701            total += ri;
702        }
703    }
704    if total <= 0.0 {
705        return Some(draw_sparse(p, rng));
706    }
707    for c in res.iter_mut() {
708        c.1 /= total;
709    }
710    Some(draw_sparse(res, rng))
711}
712
713/// The distribution the sampler would draw from — the whole chain minus
714/// the draw — as a normalized vector over the vocab, in `out`. Greedy
715/// configs (and the filtered-out fallback) come back as a one-hot, so a
716/// caller can treat every configuration uniformly. This is what
717/// speculative SAMPLING needs from both the draft head and the verify:
718/// accept-with-min(1, p/q), correct from max(0, p − q).
719pub fn distribution_into(
720    logits: &[f32],
721    config: &SamplerConfig,
722    past_tokens: &[u32],
723    scratch: &mut SamplerScratch,
724    pool: Option<&crate::pool::Pool>,
725    out: &mut Vec<f32>,
726) {
727    let one_hot = |out: &mut Vec<f32>, t: usize, n: usize| {
728        out.clear();
729        out.resize(n, 0.0);
730        if t < n {
731            out[t] = 1.0;
732        }
733    };
734    if config.temperature < 1e-6
735        && config.repetition_penalty == 1.0
736        && config.presence_penalty == 0.0
737        && config.suppress_tokens.is_empty()
738    {
739        return one_hot(out, argmax(logits) as usize, logits.len());
740    }
741    let mut probs = std::mem::take(&mut scratch.probs);
742    let normalized = chain(logits, config, past_tokens, scratch, pool, &mut probs);
743    if config.temperature < 1e-6 {
744        let t = argmax(&probs) as usize;
745        scratch.probs = probs;
746        return one_hot(out, t, logits.len());
747    }
748    if !normalized {
749        scratch.probs = probs;
750        return one_hot(out, argmax(logits) as usize, logits.len());
751    }
752    out.clear();
753    out.extend_from_slice(&probs);
754    scratch.probs = probs;
755}
756
757/// Draw from a normalized distribution with the caller's RNG.
758pub fn draw(probs: &[f32], rng: &mut SplitMix64) -> u32 {
759    categorical_sample(probs, rng.next_f32())
760}
761
762/// One step of speculative sampling (Leviathan et al. / Chen et al.):
763/// the draft `d` was drawn from `q`; the target distribution at the same
764/// position is `p`. Returns `None` when `d` is accepted (with probability
765/// min(1, p[d]/q[d])) and `Some(c)` when it is rejected, `c` drawn from
766/// the residual max(0, p − q) renormalized — which is exactly what makes
767/// the emitted token stream distributed as `p`, draft or no draft. When
768/// the residual is empty (p ⊆ q, so p == q on the support) the correction
769/// falls back to a draw from `p` itself. `scratch` holds the residual;
770/// the pool spreads the vocab-wide pass.
771pub fn spec_accept_or_correct(
772    p: &[f32],
773    q: &[f32],
774    d: u32,
775    rng: &mut SplitMix64,
776    scratch: &mut Vec<f32>,
777    pool: Option<&crate::pool::Pool>,
778) -> Option<u32> {
779    let di = d as usize;
780    let (pd, qd) = (
781        p.get(di).copied().unwrap_or(0.0),
782        q.get(di).copied().unwrap_or(0.0),
783    );
784    let r = rng.next_f32();
785    // accept iff r < min(1, pd/qd)  ⇔  r·qd < pd (qd > 0 since d was drawn from q)
786    if qd > 0.0 && r * qd < pd {
787        return None;
788    }
789    let n = p.len().min(q.len());
790    scratch.clear();
791    scratch.extend_from_slice(&p[..n]);
792    // residual = max(0, p − q), elementwise over the pool
793    {
794        let qp = q.as_ptr() as usize;
795        let sm = crate::pool::SendMut::new(scratch.as_mut_ptr());
796        let body = move |s: usize, e: usize| {
797            // SAFETY: disjoint ranges; q outlives the (joined) dispatch.
798            let qs = unsafe { std::slice::from_raw_parts(qp as *const f32, n) };
799            for i in s..e {
800                unsafe {
801                    let x = sm.at(i);
802                    *x = (*x - qs[i]).max(0.0);
803                }
804            }
805        };
806        match pool {
807            Some(pl) if n >= PAR_MIN => pl.run_rows(n, &body),
808            _ => body(0, n),
809        }
810    }
811    let sum: f32 = scratch.iter().sum();
812    if sum > 0.0 {
813        let inv = 1.0 / sum;
814        par_map(pool, scratch, &move |v| v * inv);
815        Some(categorical_sample(scratch, rng.next_f32()))
816    } else {
817        Some(categorical_sample(&p[..n], rng.next_f32()))
818    }
819}
820
821/// Greedy: index of the maximum value.
822///
823/// Four running maxima instead of one: the scalar `max_by` carried a loop
824/// dependency through the comparison, which at a 129k vocab is a tenth of a
825/// millisecond of pure serial work per token.
826///
827/// Ties resolve to the HIGHEST index — not an arbitrary choice, it is what
828/// `Iterator::max_by` does (it keeps the last of several equal maxima) and
829/// therefore what this has always returned. `explain`'s preview compares
830/// its own argmax against what greedy emits, and that test is what catches
831/// the flip.
832pub fn argmax(values: &[f32]) -> u32 {
833    if values.is_empty() {
834        return 0;
835    }
836    let n = values.len();
837    let mut best = [(0usize, f32::NEG_INFINITY); 4];
838    for (l, b) in best.iter_mut().enumerate() {
839        b.0 = l.min(n - 1);
840    }
841    let mut i = 0;
842    while i + 4 <= n {
843        for l in 0..4 {
844            let v = values[i + l];
845            if v >= best[l].1 {
846                best[l] = (i + l, v);
847            }
848        }
849        i += 4;
850    }
851    let mut bi = best[0].0;
852    let mut bv = best[0].1;
853    for b in &best[1..] {
854        if b.1 > bv || (b.1 == bv && b.0 > bi) {
855            bi = b.0;
856            bv = b.1;
857        }
858    }
859    while i < n {
860        if values[i] >= bv {
861            bv = values[i];
862            bi = i;
863        }
864        i += 1;
865    }
866    bi as u32
867}
868
869fn softmax_inplace(logits: &mut [f32]) {
870    let max_val = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
871    let mut sum = 0.0f32;
872    for v in logits.iter_mut() {
873        *v = (*v - max_val).exp();
874        sum += *v;
875    }
876    if sum > 0.0 {
877        for v in logits.iter_mut() {
878            *v /= sum;
879        }
880    }
881}
882
883/// Below this length the pool's dispatch costs more than the pass.
884const PAR_MIN: usize = 1 << 14;
885
886/// Elementwise `buf[i] = f(buf[i])` over the pool (serial without one, or
887/// for short buffers). Each output depends on its own input alone, so the
888/// chunking cannot change a single bit.
889fn par_map(pool: Option<&crate::pool::Pool>, buf: &mut [f32], f: &(dyn Fn(f32) -> f32 + Sync)) {
890    match pool {
891        Some(p) if buf.len() >= PAR_MIN => {
892            let out = crate::pool::SendMut::new(buf.as_mut_ptr());
893            p.run_rows(buf.len(), &move |s, e| {
894                for i in s..e {
895                    // SAFETY: ranges from run_rows are disjoint and the
896                    // buffer outlives the (joined) dispatch.
897                    unsafe {
898                        let q = out.at(i);
899                        *q = f(*q);
900                    }
901                }
902            });
903        }
904        _ => {
905            for v in buf.iter_mut() {
906                *v = f(*v);
907            }
908        }
909    }
910}
911
912/// `fold(init, f32::max)` over the pool. Max is order-free on non-NaN
913/// input, so per-chunk maxima combined give the serial fold's answer.
914fn par_max(pool: Option<&crate::pool::Pool>, buf: &[f32], init: f32) -> f32 {
915    match pool {
916        Some(p) if buf.len() >= PAR_MIN => {
917            let m = std::sync::Mutex::new(init);
918            p.run_rows(buf.len(), &|s, e| {
919                let local = buf[s..e].iter().cloned().fold(init, f32::max);
920                let mut g = m.lock().unwrap();
921                *g = g.max(local);
922            });
923            m.into_inner().unwrap()
924        }
925        _ => buf.iter().cloned().fold(init, f32::max),
926    }
927}
928
929/// `softmax_inplace` with the exp and the normalisation spread over the
930/// pool. The max is order-free, the exp is elementwise, and the SUM stays
931/// a sequential index-order fold — exactly the serial loop's accumulation
932/// — so the probabilities are bit-identical.
933fn softmax_inplace_pool(pool: Option<&crate::pool::Pool>, logits: &mut [f32]) {
934    if pool.is_none() || logits.len() < PAR_MIN {
935        return softmax_inplace(logits);
936    }
937    let max_val = par_max(pool, logits, f32::NEG_INFINITY);
938    par_map(pool, logits, &move |v| (v - max_val).exp());
939    let sum: f32 = logits.iter().sum();
940    if sum > 0.0 {
941        par_map(pool, logits, &move |v| v / sum);
942    }
943}
944
945/// The k-th largest value of `probs` (k ≥ 1, k ≤ len), by the same
946/// descending `partial_cmp` order `select_nth_unstable_by` used — one
947/// streaming pass with a k-slot min-heap instead of a whole-vocab copy
948/// and partition. Values, not indices, so ties give the same threshold.
949fn kth_largest(probs: &[f32], k: usize) -> f32 {
950    use std::cmp::Ordering;
951    // Min-heap on the k largest seen so far: `heap[0]` is the smallest of
952    // them, i.e. the running k-th largest.
953    let mut heap: Vec<f32> = Vec::with_capacity(k);
954    let desc = |a: f32, b: f32| b.partial_cmp(&a).unwrap_or(Ordering::Equal);
955    let sift_down = |h: &mut [f32], mut i: usize| {
956        let n = h.len();
957        loop {
958            let (l, r) = (2 * i + 1, 2 * i + 2);
959            let mut m = i;
960            // child "smaller" in the descending order = later in it
961            if l < n && desc(h[l], h[m]) == Ordering::Greater {
962                m = l;
963            }
964            if r < n && desc(h[r], h[m]) == Ordering::Greater {
965                m = r;
966            }
967            if m == i {
968                break;
969            }
970            h.swap(i, m);
971            i = m;
972        }
973    };
974    let sift_up = |h: &mut [f32], mut i: usize| {
975        while i > 0 {
976            let parent = (i - 1) / 2;
977            if desc(h[i], h[parent]) == Ordering::Greater {
978                h.swap(i, parent);
979                i = parent;
980            } else {
981                break;
982            }
983        }
984    };
985    for &v in probs {
986        if heap.len() < k {
987            heap.push(v);
988            let n = heap.len();
989            sift_up(&mut heap, n - 1);
990        } else if desc(v, heap[0]) == Ordering::Less {
991            // v is larger than the current k-th largest: replace it.
992            heap[0] = v;
993            sift_down(&mut heap, 0);
994        }
995    }
996    heap[0]
997}
998
999/// `apply_top_k` without the second vocab-sized copy: the threshold is
1000/// the k-th largest value from one streaming pass, the zeroing is an
1001/// elementwise pass over the pool. Same kept set, same values.
1002fn apply_top_k_pool(pool: Option<&crate::pool::Pool>, probs: &mut [f32], k: usize) {
1003    if k == 0 || k >= probs.len() {
1004        return;
1005    }
1006    let threshold = kth_largest(probs, k);
1007    par_map(pool, probs, &move |p| if p < threshold { 0.0 } else { p });
1008}
1009
1010/// Top-1 probability of `id` under a softmax at temperature `temp` — the
1011/// per-token confidence — with the exp pass over the pool and the sum
1012/// sequential in index order (bit-identical to the serial fold). Uses
1013/// the scratch's partition buffer, idle now that top-k streams.
1014pub fn top1_prob_pool(
1015    pool: Option<&crate::pool::Pool>,
1016    scratch: &mut SamplerScratch,
1017    logits: &[f32],
1018    id: u32,
1019    temp: f32,
1020) -> f32 {
1021    let t = if temp > 1e-3 { temp } else { 1.0 };
1022    let max = par_max(pool, logits, f32::NEG_INFINITY);
1023    let mut e = std::mem::take(&mut scratch.topk);
1024    e.clear();
1025    e.extend_from_slice(logits);
1026    par_map(pool, &mut e, &move |v| ((v - max) / t).exp());
1027    let sum: f32 = e.iter().sum();
1028    let out = if sum > 0.0 {
1029        (((logits[id as usize] - max) / t).exp()) / sum
1030    } else {
1031        0.0
1032    };
1033    scratch.topk = e;
1034    out
1035}
1036
1037fn apply_repetition_penalty(
1038    logits: &mut [f32],
1039    past_tokens: &[u32],
1040    penalty: f32,
1041    scratch: &mut SamplerScratch,
1042) {
1043    let epoch = scratch.begin_seen(logits.len());
1044    for &tok in past_tokens {
1045        let idx = tok as usize;
1046        if idx < logits.len() && scratch.seen_epoch[idx] != epoch {
1047            scratch.seen_epoch[idx] = epoch;
1048            if logits[idx] > 0.0 {
1049                logits[idx] /= penalty;
1050            } else {
1051                logits[idx] *= penalty;
1052            }
1053        }
1054    }
1055}
1056
1057/// Keep the k highest-probability tokens (plus exact ties at the
1058/// threshold), zero the rest. Selection, not a full vocab sort — the
1059/// old double `sort_by` over ~150k probs was ~1ms of pure per-token
1060/// overhead (roadmap §3 P0).
1061fn apply_top_k(probs: &mut [f32], k: usize, sel: &mut Vec<f32>) {
1062    if k == 0 || k >= probs.len() {
1063        return;
1064    }
1065    // `select_nth_unstable` permutes, so the partition needs its own
1066    // buffer — but not a FRESH one each token.
1067    sel.clear();
1068    sel.extend_from_slice(probs);
1069    // k-th largest = (k-1)-th index in a descending partition.
1070    let (_, kth, _) = sel.select_nth_unstable_by(k - 1, |a, b| {
1071        b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
1072    });
1073    let threshold = *kth;
1074    for p in probs.iter_mut() {
1075        if *p < threshold {
1076            *p = 0.0;
1077        }
1078    }
1079}
1080
1081/// Nucleus: keep the smallest prefix of tokens whose cumulative
1082/// probability reaches top_p. Only surviving (non-zero) candidates are
1083/// sorted — after top-k that is ≤ k elements, not the whole vocab; the
1084/// kept set is marked in-place instead of a per-token HashSet.
1085fn apply_top_p(probs: &mut [f32], top_p: f32) {
1086    let mut indexed: Vec<(usize, f32)> = probs
1087        .iter()
1088        .copied()
1089        .enumerate()
1090        .filter(|&(_, p)| p > 0.0)
1091        .collect();
1092    indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1093
1094    let mut cumsum = 0.0f32;
1095    let mut cutoff_idx = indexed.len();
1096    for (i, &(_, prob)) in indexed.iter().enumerate() {
1097        cumsum += prob;
1098        if cumsum >= top_p {
1099            cutoff_idx = i + 1;
1100            break;
1101        }
1102    }
1103
1104    // Zero the dropped tail directly — indices, not membership tests.
1105    for &(i, _) in &indexed[cutoff_idx..] {
1106        probs[i] = 0.0;
1107    }
1108}
1109
1110/// Inverse-CDF sampling with an externally supplied uniform r ∈ [0, 1).
1111fn categorical_sample(probs: &[f32], r: f32) -> u32 {
1112    let mut cumsum = 0.0f32;
1113    for (i, &p) in probs.iter().enumerate() {
1114        cumsum += p;
1115        if r < cumsum {
1116            return i as u32;
1117        }
1118    }
1119    probs.iter().rposition(|&p| p > 0.0).unwrap_or(0) as u32
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    /// The four-lane argmax against the obvious scalar one, including the
1125    /// tie rule: `max_by` keeps the LAST of several equal maxima, and a
1126    /// lane split that quietly picked the first would move greedy output on
1127    /// any model with two equally-likely tokens.
1128    #[test]
1129    fn argmax_lanes_match_the_scalar_one_ties_and_all() {
1130        // The reference IS the old implementation, `max_by` and all — the
1131        // point is that nothing observable changed, tie rule included.
1132        let scalar = |v: &[f32]| -> u32 {
1133            v.iter()
1134                .enumerate()
1135                .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
1136                .map(|(i, _)| i as u32)
1137                .unwrap_or(0)
1138        };
1139        for n in 0..40usize {
1140            for seed in 0..8u64 {
1141                let mut r = super::SplitMix64::new(seed * 7 + n as u64);
1142                // Quantized to few distinct values on purpose: ties are the
1143                // case the lanes can get wrong and random floats never hit.
1144                let v: Vec<f32> = (0..n).map(|_| ((r.next_u64() % 5) as f32) - 2.0).collect();
1145                assert_eq!(super::argmax(&v), scalar(&v), "n={n} seed={seed} {v:?}");
1146            }
1147        }
1148        let flat = vec![f32::NEG_INFINITY; 13];
1149        assert_eq!(super::argmax(&flat), scalar(&flat));
1150    }
1151
1152    use super::*;
1153
1154    #[test]
1155    fn test_argmax() {
1156        let logits = vec![0.1, 0.5, 0.3, 0.9, 0.2];
1157        assert_eq!(argmax(&logits), 3);
1158    }
1159
1160    #[test]
1161    fn test_greedy_sampling() {
1162        let logits = vec![1.0, 5.0, 2.0, 3.0];
1163        let config = SamplerConfig {
1164            temperature: 0.0,
1165            ..Default::default()
1166        };
1167        let mut rng = SplitMix64::new(1);
1168        assert_eq!(sample(&logits, &config, &[], &mut rng), 1);
1169    }
1170
1171    /// `argmax_penalized` must equal the copy-and-penalize chain's argmax
1172    /// — same values, same tie rule — with and without the pool.
1173    #[test]
1174    fn argmax_penalized_matches_chain_argmax() {
1175        let pool = crate::pool::Pool::new(3);
1176        let n = 40_000usize;
1177        for seed in 0..8u64 {
1178            let mut r = SplitMix64::new(seed + 3);
1179            // coarse values so ties happen
1180            let logits: Vec<f32> = (0..n)
1181                .map(|_| ((r.next_u64() % 41) as f32 - 20.0) / 4.0)
1182                .collect();
1183            let past: Vec<u32> = (0..2000)
1184                .map(|_| (r.next_u64() % n as u64) as u32)
1185                .collect();
1186            for cfg in [
1187                SamplerConfig {
1188                    temperature: 0.0,
1189                    repetition_penalty: 1.1,
1190                    ..Default::default()
1191                },
1192                SamplerConfig {
1193                    temperature: 0.0,
1194                    repetition_penalty: 1.0,
1195                    presence_penalty: 1.5,
1196                    ..Default::default()
1197                },
1198                SamplerConfig {
1199                    temperature: 0.0,
1200                    repetition_penalty: 1.3,
1201                    presence_penalty: 0.7,
1202                    suppress_tokens: vec![5, 77, 3000],
1203                    ..Default::default()
1204                },
1205            ] {
1206                let mut s1 = SamplerScratch::default();
1207                let mut probs = Vec::new();
1208                chain(&logits, &cfg, &past, &mut s1, None, &mut probs);
1209                let want = argmax(&probs);
1210                let mut s2 = SamplerScratch::default();
1211                let got_serial = argmax_penalized(&logits, &cfg, &past, &mut s2, None);
1212                let got_pool = argmax_penalized(&logits, &cfg, &past, &mut s2, Some(&pool));
1213                assert_eq!(want, got_serial, "serial, seed {seed} cfg {cfg:?}");
1214                assert_eq!(want, got_pool, "pool, seed {seed} cfg {cfg:?}");
1215            }
1216        }
1217    }
1218
1219    /// The pool chain must sample the SAME token as the serial one on the
1220    /// same seed — that is the whole contract of the parallel passes.
1221    #[test]
1222    fn pool_chain_matches_serial_bit_for_bit() {
1223        let pool = crate::pool::Pool::new(3);
1224        let n = 40_000usize; // above PAR_MIN so the pool arm is exercised
1225        for seed in 0..6u64 {
1226            let mut r = SplitMix64::new(seed + 11);
1227            let logits: Vec<f32> = (0..n)
1228                .map(|i| ((r.next_u64() % 2001) as f32 - 1000.0) / 90.0 + (i % 7) as f32 * 0.01)
1229                .collect();
1230            let past: Vec<u32> = (0..500).map(|_| (r.next_u64() % n as u64) as u32).collect();
1231            for cfg in [
1232                SamplerConfig::default(),
1233                SamplerConfig {
1234                    temperature: 0.7,
1235                    top_p: 0.8,
1236                    top_k: 20,
1237                    min_p: 0.0,
1238                    presence_penalty: 1.5,
1239                    repetition_penalty: 1.0,
1240                    ..Default::default()
1241                },
1242                SamplerConfig {
1243                    temperature: 1.0,
1244                    top_p: 0.95,
1245                    top_k: 20,
1246                    min_p: 0.0,
1247                    ..Default::default()
1248                },
1249                SamplerConfig {
1250                    temperature: 1.3,
1251                    top_p: 1.0,
1252                    top_k: 0,
1253                    min_p: 0.02,
1254                    ..Default::default()
1255                },
1256            ] {
1257                let mut s1 = SamplerScratch::default();
1258                let mut s2 = SamplerScratch::default();
1259                for step in 0..5u64 {
1260                    let mut r1 = SplitMix64::new(seed * 100 + step);
1261                    let mut r2 = r1.clone();
1262                    let a = sample_with_scratch(&logits, &cfg, &past, &mut r1, &mut s1);
1263                    let b = sample_with_scratch_pool(
1264                        &logits,
1265                        &cfg,
1266                        &past,
1267                        &mut r2,
1268                        &mut s2,
1269                        Some(&pool),
1270                    );
1271                    assert_eq!(a, b, "seed {seed} step {step} cfg {cfg:?}");
1272                    // and the working copies agree value for value
1273                    assert_eq!(s1.probs, s2.probs, "probs differ seed {seed} step {step}");
1274                }
1275            }
1276        }
1277    }
1278
1279    /// Speculative sampling must reproduce the TARGET distribution however
1280    /// good or bad the draft is: draw d ~ q, accept with min(1, p/q), else
1281    /// correct from max(0, p − q). Empirical law over many trials against
1282    /// p itself, for a sharp draft, a flat draft and a wrong draft.
1283    #[test]
1284    fn spec_accept_or_correct_reproduces_the_target() {
1285        let n = 40usize;
1286        let mk = |seed: u64, sharp: f32| -> Vec<f32> {
1287            let mut r = SplitMix64::new(seed);
1288            let mut v: Vec<f32> = (0..n)
1289                .map(|_| ((r.next_u64() % 1000) as f32 / 1000.0).powf(sharp))
1290                .collect();
1291            // a few exact zeros, like a top-k'd distribution
1292            for i in 0..n {
1293                if (i * 7 + seed as usize) % 5 == 0 {
1294                    v[i] = 0.0;
1295                }
1296            }
1297            let s: f32 = v.iter().sum();
1298            v.iter().map(|x| x / s).collect()
1299        };
1300        let p = mk(3, 3.0);
1301        for (qi, q) in [mk(3, 3.0), mk(11, 1.0), mk(29, 6.0)]
1302            .into_iter()
1303            .enumerate()
1304        {
1305            let mut rng = SplitMix64::new(77 + qi as u64);
1306            let mut counts = vec![0u64; n];
1307            let mut scratch = Vec::new();
1308            let trials = 400_000u64;
1309            for _ in 0..trials {
1310                let d = categorical_sample(&q, rng.next_f32());
1311                let t = match spec_accept_or_correct(&p, &q, d, &mut rng, &mut scratch, None) {
1312                    None => d,
1313                    Some(c) => c,
1314                };
1315                counts[t as usize] += 1;
1316            }
1317            let l1: f64 = (0..n)
1318                .map(|i| (counts[i] as f64 / trials as f64 - p[i] as f64).abs())
1319                .sum();
1320            eprintln!("spec q#{qi}: L1(empirical, p) = {l1:.4}");
1321            assert!(
1322                l1 < 0.01,
1323                "q#{qi}: empirical distribution drifted from p, L1 {l1}"
1324            );
1325            // and nothing outside p's support was ever emitted
1326            for i in 0..n {
1327                if p[i] == 0.0 {
1328                    assert_eq!(counts[i], 0, "q#{qi}: token {i} outside p emitted");
1329                }
1330            }
1331        }
1332    }
1333
1334    /// The sparse chain is the dense chain: same survivor set, same
1335    /// probabilities (to fp), serial and pooled, with and without
1336    /// penalties, min-p, top-p — and a seed draws the same token.
1337    #[test]
1338    fn sparse_chain_matches_the_dense_chain() {
1339        let pool = crate::pool::Pool::new(3);
1340        let n = 40_000usize; // above PAR_MIN: the pooled arms run
1341        for seed in 0..5u64 {
1342            let mut r = SplitMix64::new(100 + seed);
1343            let logits: Vec<f32> = (0..n)
1344                .map(|_| ((r.next_u64() % 3000) as f32 - 1500.0) / 120.0)
1345                .collect();
1346            let past: Vec<u32> = (0..400).map(|_| (r.next_u64() % n as u64) as u32).collect();
1347            for cfg in [
1348                SamplerConfig {
1349                    temperature: 0.7,
1350                    top_p: 0.8,
1351                    top_k: 20,
1352                    min_p: 0.0,
1353                    presence_penalty: 1.5,
1354                    repetition_penalty: 1.0,
1355                    ..Default::default()
1356                },
1357                SamplerConfig {
1358                    temperature: 1.0,
1359                    top_p: 0.95,
1360                    top_k: 40,
1361                    min_p: 0.05,
1362                    presence_penalty: 0.0,
1363                    repetition_penalty: 1.1,
1364                    ..Default::default()
1365                },
1366                SamplerConfig {
1367                    temperature: 0.6,
1368                    top_p: 1.0,
1369                    top_k: 3,
1370                    min_p: 0.0,
1371                    presence_penalty: 0.0,
1372                    repetition_penalty: 1.0,
1373                    suppress_tokens: vec![5, 6, 7],
1374                    ..Default::default()
1375                },
1376            ] {
1377                assert!(sparse_ok(&cfg));
1378                let mut sd = SamplerScratch::default();
1379                let mut dense = Vec::new();
1380                distribution_into(&logits, &cfg, &past, &mut sd, None, &mut dense);
1381                for pl in [None, Some(&pool)] {
1382                    let mut ss = SamplerScratch::default();
1383                    let mut sp = Vec::new();
1384                    let ok = sparse_distribution_into(&logits, &cfg, &past, &mut ss, pl, &mut sp);
1385                    assert!(ok, "seed {seed} cfg {cfg:?}");
1386                    let dense_nz: Vec<(u32, f32)> = dense
1387                        .iter()
1388                        .enumerate()
1389                        .filter(|&(_, &v)| v > 0.0)
1390                        .map(|(i, &v)| (i as u32, v))
1391                        .collect();
1392                    assert_eq!(
1393                        dense_nz.len(),
1394                        sp.len(),
1395                        "seed {seed} pool {} cfg {cfg:?}: support {:?} vs {:?}",
1396                        pl.is_some(),
1397                        dense_nz,
1398                        sp
1399                    );
1400                    for (a, b) in dense_nz.iter().zip(sp.iter()) {
1401                        assert_eq!(a.0, b.0, "seed {seed} cfg {cfg:?}: ids differ");
1402                        assert!(
1403                            (a.1 - b.1).abs() <= 2e-5 * a.1.max(1e-3),
1404                            "seed {seed} cfg {cfg:?}: prob {} vs {}",
1405                            a.1,
1406                            b.1
1407                        );
1408                    }
1409                    // the seed lands on the same token (both walk the ids
1410                    // in order); allow a boundary rounding miss or two
1411                    let mut agree = 0usize;
1412                    let trials = 400usize;
1413                    for k in 0..trials as u64 {
1414                        let mut r1 = SplitMix64::new(500 + k);
1415                        let mut r2 = SplitMix64::new(500 + k);
1416                        let a = categorical_sample(&dense, r1.next_f32());
1417                        let b = draw_sparse(&sp, &mut r2);
1418                        agree += (a == b) as usize;
1419                    }
1420                    assert!(agree >= trials - 2, "seed {seed} cfg {cfg:?}: agree {agree}/{trials}");
1421                    // and the public entry uses it
1422                    let mut r1 = SplitMix64::new(9);
1423                    let mut r2 = SplitMix64::new(9);
1424                    let a = categorical_sample(&dense, r1.next_f32());
1425                    let mut s3 = SamplerScratch::default();
1426                    let b = sample_with_scratch_pool(&logits, &cfg, &past, &mut r2, &mut s3, pl);
1427                    assert_eq!(a, b, "seed {seed} cfg {cfg:?}: entry draw");
1428                }
1429            }
1430        }
1431    }
1432
1433    /// The sparse accept/correct emits the target distribution, like its
1434    /// dense twin — the same 400k-trial law test over sparse p and q.
1435    #[test]
1436    fn spec_accept_or_correct_sparse_reproduces_the_target() {
1437        let n = 40usize;
1438        let mk = |seed: u64, sharp: f32| -> Vec<(u32, f32)> {
1439            let mut r = SplitMix64::new(seed);
1440            let mut v: Vec<f32> = (0..n)
1441                .map(|_| ((r.next_u64() % 1000) as f32 / 1000.0).powf(sharp))
1442                .collect();
1443            for i in 0..n {
1444                if (i * 7 + seed as usize) % 5 == 0 {
1445                    v[i] = 0.0;
1446                }
1447            }
1448            let s: f32 = v.iter().sum();
1449            v.iter()
1450                .enumerate()
1451                .filter(|&(_, &x)| x > 0.0)
1452                .map(|(i, &x)| (i as u32, x / s))
1453                .collect()
1454        };
1455        let p = mk(3, 3.0);
1456        for (qi, q) in [mk(3, 3.0), mk(11, 1.0), mk(29, 6.0)]
1457            .into_iter()
1458            .enumerate()
1459        {
1460            let mut rng = SplitMix64::new(77 + qi as u64);
1461            let mut counts = vec![0u64; n];
1462            let mut res = Vec::new();
1463            let trials = 400_000u64;
1464            for _ in 0..trials {
1465                let d = draw_sparse(&q, &mut rng);
1466                let t = match spec_accept_or_correct_sparse(&p, &q, d, &mut rng, &mut res) {
1467                    None => d,
1468                    Some(c) => c,
1469                };
1470                counts[t as usize] += 1;
1471            }
1472            let l1: f64 = (0..n)
1473                .map(|i| (counts[i] as f64 / trials as f64 - sparse_get(&p, i as u32) as f64).abs())
1474                .sum();
1475            eprintln!("sparse spec q#{qi}: L1(empirical, p) = {l1:.4}");
1476            assert!(l1 < 0.01, "q#{qi}: drifted, L1 {l1}");
1477            for i in 0..n {
1478                if sparse_get(&p, i as u32) == 0.0 {
1479                    assert_eq!(counts[i], 0, "q#{qi}: token {i} outside p emitted");
1480                }
1481            }
1482        }
1483    }
1484
1485    /// `distribution_into` is the sampler's own chain: drawing from it
1486    /// with the same uniform lands on the same token as `sample_*`.
1487    #[test]
1488    fn distribution_matches_the_sampler_draw() {
1489        let n = 20_000usize;
1490        let mut r = SplitMix64::new(9);
1491        let logits: Vec<f32> = (0..n)
1492            .map(|_| ((r.next_u64() % 3000) as f32 - 1500.0) / 120.0)
1493            .collect();
1494        let past: Vec<u32> = (0..300).map(|_| (r.next_u64() % n as u64) as u32).collect();
1495        for cfg in [
1496            SamplerConfig::default(),
1497            SamplerConfig {
1498                temperature: 0.7,
1499                top_p: 0.8,
1500                top_k: 20,
1501                min_p: 0.0,
1502                presence_penalty: 1.5,
1503                repetition_penalty: 1.0,
1504                ..Default::default()
1505            },
1506            SamplerConfig {
1507                temperature: 0.0,
1508                repetition_penalty: 1.1,
1509                ..Default::default()
1510            },
1511            SamplerConfig {
1512                temperature: 0.0,
1513                repetition_penalty: 1.0,
1514                presence_penalty: 0.0,
1515                ..Default::default()
1516            },
1517        ] {
1518            let mut s1 = SamplerScratch::default();
1519            let mut s2 = SamplerScratch::default();
1520            for step in 0..4u64 {
1521                let mut r1 = SplitMix64::new(step + 1);
1522                let mut r2 = r1.clone();
1523                let a = sample_with_scratch(&logits, &cfg, &past, &mut r1, &mut s1);
1524                let mut dist = Vec::new();
1525                distribution_into(&logits, &cfg, &past, &mut s2, None, &mut dist);
1526                assert!(
1527                    (dist.iter().sum::<f32>() - 1.0).abs() < 1e-3,
1528                    "not normalized: {}",
1529                    dist.iter().sum::<f32>()
1530                );
1531                let b = if cfg.temperature < 1e-6 {
1532                    argmax(&dist)
1533                } else {
1534                    draw(&dist, &mut r2)
1535                };
1536                assert_eq!(a, b, "cfg {cfg:?} step {step}");
1537            }
1538        }
1539    }
1540
1541    /// The streaming k-th largest against `select_nth_unstable_by` — the
1542    /// value the old partition returned, ties and zeros included.
1543    #[test]
1544    fn kth_largest_equals_select_nth() {
1545        for seed in 0..20u64 {
1546            let mut r = SplitMix64::new(seed);
1547            let n = 50 + (r.next_u64() % 3000) as usize;
1548            let v: Vec<f32> = (0..n)
1549                .map(|_| {
1550                    if r.next_u64() % 3 == 0 {
1551                        0.0
1552                    } else {
1553                        (r.next_u64() % 97) as f32 / 97.0
1554                    }
1555                })
1556                .collect();
1557            for k in [1usize, 2, 5, 20, 40, n / 2, n - 1] {
1558                let mut sel = v.clone();
1559                let (_, kth, _) = sel.select_nth_unstable_by(k - 1, |a, b| {
1560                    b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
1561                });
1562                assert_eq!(kth_largest(&v, k), *kth, "seed {seed} k {k}");
1563            }
1564        }
1565    }
1566
1567    /// The pooled confidence equals the serial formula, bit for bit.
1568    #[test]
1569    fn top1_prob_pool_matches_serial() {
1570        let pool = crate::pool::Pool::new(2);
1571        let n = 30_000usize;
1572        let mut r = SplitMix64::new(5);
1573        let logits: Vec<f32> = (0..n)
1574            .map(|_| ((r.next_u64() % 1000) as f32) / 37.0)
1575            .collect();
1576        let serial = |id: u32, temp: f32| -> f32 {
1577            let t = if temp > 1e-3 { temp } else { 1.0 };
1578            let max = logits.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
1579            let sum: f32 = logits.iter().map(|&v| ((v - max) / t).exp()).sum();
1580            (((logits[id as usize] - max) / t).exp()) / sum
1581        };
1582        let mut sc = SamplerScratch::default();
1583        for (id, t) in [(3u32, 1.0f32), (777, 0.7), (29_999, 2.0), (12, 0.0)] {
1584            let a = serial(id, t);
1585            let b = top1_prob_pool(Some(&pool), &mut sc, &logits, id, t);
1586            assert_eq!(a.to_bits(), b.to_bits(), "id {id} t {t}: {a} vs {b}");
1587        }
1588    }
1589
1590    #[test]
1591    fn test_softmax() {
1592        let mut logits = vec![1.0, 2.0, 3.0];
1593        softmax_inplace(&mut logits);
1594        let sum: f32 = logits.iter().sum();
1595        assert!((sum - 1.0).abs() < 1e-5);
1596        assert!(logits[2] > logits[1] && logits[1] > logits[0]);
1597    }
1598
1599    #[test]
1600    fn test_repetition_penalty() {
1601        let mut logits = vec![1.0, 2.0, 3.0, 4.0];
1602        let mut scratch = SamplerScratch::default();
1603        apply_repetition_penalty(&mut logits, &[1, 3], 2.0, &mut scratch);
1604        assert_eq!(logits, vec![1.0, 1.0, 3.0, 2.0]);
1605    }
1606
1607    #[test]
1608    fn repetition_penalty_applies_once_per_unique_token() {
1609        let mut logits = vec![1.0, 4.0, -6.0];
1610        let mut scratch = SamplerScratch::default();
1611        apply_repetition_penalty(&mut logits, &[1, 1, 2, 1, 2], 2.0, &mut scratch);
1612        assert_eq!(logits, vec![1.0, 2.0, -12.0]);
1613    }
1614
1615    #[test]
1616    fn top_k_keeps_exactly_k() {
1617        let mut probs = vec![0.1, 0.4, 0.05, 0.3, 0.15];
1618        apply_top_k(&mut probs, 2, &mut Vec::new());
1619        let kept = probs.iter().filter(|&&p| p > 0.0).count();
1620        assert_eq!(kept, 2, "top-k must keep exactly k (was k+1 in v1)");
1621        assert!(probs[1] > 0.0 && probs[3] > 0.0);
1622    }
1623
1624    #[test]
1625    fn rng_reaches_full_cdf() {
1626        // v1 bug: r < 0.233 always, so the CDF tail was unreachable.
1627        // With uniform probs the LAST index must be sampled sometimes.
1628        let probs = vec![0.25f32; 4];
1629        let mut rng = SplitMix64::new(42);
1630        let mut hits = [0usize; 4];
1631        for _ in 0..4000 {
1632            let i = categorical_sample(&probs, rng.next_f32()) as usize;
1633            hits[i] += 1;
1634        }
1635        for (i, &h) in hits.iter().enumerate() {
1636            assert!(h > 700, "index {i} sampled only {h}/4000 — biased RNG");
1637        }
1638    }
1639
1640    #[test]
1641    fn same_seed_same_sequence() {
1642        let logits: Vec<f32> = (0..32).map(|i| (i as f32 * 0.37).sin()).collect();
1643        let config = SamplerConfig {
1644            temperature: 1.0,
1645            seed: Some(7),
1646            ..Default::default()
1647        };
1648        let run = |seed: u64| -> Vec<u32> {
1649            let mut rng = SplitMix64::new(seed);
1650            (0..16)
1651                .map(|_| sample(&logits, &config, &[], &mut rng))
1652                .collect()
1653        };
1654        assert_eq!(run(7), run(7), "same seed must reproduce");
1655        assert_ne!(run(7), run(8), "different seed must differ");
1656    }
1657}