Skip to main content

memra_sampling/
lib.rs

1//! Host-side sampler chain (BASE-2, MEMRA-BUILD-MAP §BASE-2). Ports llama.cpp CPU sampler
2//! semantics (llama-sampler.cpp): repetition/freq/presence penalties -> temperature -> top-k ->
3//! top-p -> min-p -> categorical draw. Greedy (temp<=0) = argmax, the bit-exact reference.
4//!
5//! Runs on the host over the full [n_vocab] f32 logit vector already brought back by the per-step
6//! D2H sync (decode.rs) — at B=2-4 this is single-µs, no GPU kernel needed (the GPU-fused sampler
7//! is a deferred PERF item, only needed once CUDA-graph removes the D2H barrier).
8
9/// Sampler configuration. Defaults = greedy (temp 0). Order of application matches llama.cpp.
10#[derive(Clone, Debug)]
11pub struct SamplerConfig {
12    pub temperature: f32,      // <= 0.0 => greedy argmax (penalties/top-k/p ignored)
13    pub top_k: usize,          // 0 => disabled (keep all)
14    pub top_p: f32,            // 1.0 => disabled
15    pub min_p: f32,            // 0.0 => disabled
16    pub penalty_last_n: usize, // window of recent tokens for penalties (0 => disabled)
17    pub penalty_repeat: f32,   // 1.0 => disabled (llama default 1.0)
18    pub penalty_freq: f32,     // 0.0 => disabled
19    pub penalty_present: f32,  // 0.0 => disabled
20    pub seed: u64,
21}
22
23impl Default for SamplerConfig {
24    fn default() -> Self {
25        SamplerConfig {
26            temperature: 0.0,
27            top_k: 0,
28            top_p: 1.0,
29            min_p: 0.0,
30            penalty_last_n: 0,
31            penalty_repeat: 1.0,
32            penalty_freq: 0.0,
33            penalty_present: 0.0,
34            seed: 0,
35        }
36    }
37}
38
39/// SESSION-RESUME SAMPLER IDENTITY (lane/session-resume-sampler-predicate-20260820; receipts
40/// `research/spec-cache-20260818/SESSION-RESUME-PREDICATE.md`).
41///
42/// The canonical form of a request's sampler, for exactly one question: **may this request resume
43/// a parked whole session that some OTHER request's sampler shaped?** The spec pool's resume probe
44/// compared prompts and never samplers — that omission is how a filtered request inherited a draft
45/// graph captured unfiltered (`memra-engine` `SampledGraphKey`, lane/graph-s-key-exactness-
46/// 20260819). Keying the graph closed the exactness hole; it did not make cross-sampler resume
47/// SOUND, and the house posture in that situation is refuse-on-ambiguity with the refusal naming
48/// itself. This type is that predicate.
49///
50/// CANONICALIZATION, and why each rule is safe. Two encodings that name the same program must
51/// compare equal, or the predicate refuses resumes that cost nothing to allow:
52/// - `temperature <= 0.0` is GREEDY — `-1.0` and `0.0` are one program, so `temp_bits` is pinned
53///   to `0.0` in that regime and `greedy` carries the distinction. The greedy/sampled flip is
54///   itself a refusal: the two arms consume a parked `next_pred`/`pending_tok` differently and
55///   engage different captured draft graphs.
56/// - `top_k == 0`, `top_p >= 1.0`, `min_p <= 0.0` are each the OFF sentinel (matching
57///   `is_spec_sampling` and `SampledGraphKey::pure_temp`), canonicalized so `top_p 1.5` and
58///   `top_p 1.0` do not look like a change.
59/// - Penalties are OFF as a group iff `penalty_last_n == 0` or all three coefficients are neutral
60///   — the same `pen_on` predicate `spec.rs` computes. Off canonicalizes to the whole disabled
61///   tuple, so `penalty_last_n 64` with neutral coefficients equals penalties absent.
62///
63/// Float fields compare by BITS after canonicalization (no NaN/`-0.0` surprise), the same
64/// discipline `SampledGraphKey` uses.
65///
66/// `seed` IS carried and DELIBERATELY NOT COMPARED — see [`SamplerIdentity::mismatch`].
67#[derive(Clone, Copy, PartialEq, Eq, Debug)]
68pub struct SamplerIdentity {
69    greedy: bool,
70    temp_bits: u32,
71    /// Carried for the record and for callers that want to log it; NOT part of `mismatch`.
72    seed: u64,
73    top_k: usize,
74    top_p_bits: u32,
75    min_p_bits: u32,
76    penalty_last_n: usize,
77    penalty_repeat_bits: u32,
78    penalty_freq_bits: u32,
79    penalty_present_bits: u32,
80}
81
82impl SamplerIdentity {
83    /// Canonical identity of a sampler configuration.
84    pub fn of(cfg: &SamplerConfig) -> Self {
85        let greedy = cfg.temperature <= 0.0;
86        let pen_on = cfg.penalty_last_n > 0
87            && (cfg.penalty_repeat != 1.0 || cfg.penalty_freq != 0.0 || cfg.penalty_present != 0.0);
88        SamplerIdentity {
89            greedy,
90            temp_bits: if greedy { 0.0f32 } else { cfg.temperature }.to_bits(),
91            seed: cfg.seed,
92            top_k: cfg.top_k,
93            top_p_bits: if cfg.top_p >= 1.0 { 1.0f32 } else { cfg.top_p }.to_bits(),
94            min_p_bits: if cfg.min_p <= 0.0 { 0.0f32 } else { cfg.min_p }.to_bits(),
95            penalty_last_n: if pen_on { cfg.penalty_last_n } else { 0 },
96            penalty_repeat_bits: if pen_on { cfg.penalty_repeat } else { 1.0f32 }.to_bits(),
97            penalty_freq_bits: if pen_on { cfg.penalty_freq } else { 0.0f32 }.to_bits(),
98            penalty_present_bits: if pen_on { cfg.penalty_present } else { 0.0f32 }.to_bits(),
99        }
100    }
101
102    /// The seed this identity was built from (logging/receipts only — never compared).
103    pub fn seed(&self) -> u64 {
104        self.seed
105    }
106
107    /// The FIRST field on which `self` (an incoming request) differs from `parked` (the sampler
108    /// that shaped a parked session), as a stable name for the refusal line — `None` when the two
109    /// samplers are equivalent and the resume is legal. A refusal that does not say why is
110    /// indistinguishable from an unwired mechanism, so the name is the deliverable, not a nicety.
111    ///
112    /// Order is fixed and coarsest-first (`regime` before the field that only exists inside one
113    /// regime), so the reported name is the most informative one rather than an artifact of struct
114    /// layout.
115    ///
116    /// **`seed` IS NOT COMPARED, deliberately.** It is the one sampler field a resume may change,
117    /// for two reasons that are both mechanical:
118    /// - The only parked state that BAKES the seed is the sampled draft graph, and
119    ///   `SampledGraphKey` already carries `seed`: a seed change drops the parked graph and
120    ///   recaptures. (`memra-engine` `spec.rs`; pinned by `seed_alone_still_rekeys_the_draft_graph`
121    ///   in that crate's `sampled_graph_key` tests.)
122    /// - The session's persisted Philox counters (`SpecSession::sctr/uctr`) are counter-based:
123    ///   `philox(seed', ctr)` continued from another seed's counter position is an independent
124    ///   stream, not a repeated one. Reproducibility is already scoped per `(seed, session)`
125    ///   rather than per seed (`memra-server` `worker.rs`, the spec-burst sampling note), so a
126    ///   changed seed costs nothing that same-seed resume was not already costing.
127    ///
128    /// Comparing it would refuse essentially ALL sampled traffic: omitting `seed` on a serve
129    /// request draws fresh per-request entropy, so every turn of every seed-omitting conversation
130    /// would carry a "changed" seed. That is a cost with no soundness gain, which is exactly the
131    /// trade this predicate exists to make explicitly rather than by accident.
132    pub fn mismatch(&self, parked: &Self) -> Option<&'static str> {
133        if self.greedy != parked.greedy {
134            return Some("regime");
135        }
136        if self.temp_bits != parked.temp_bits {
137            return Some("temperature");
138        }
139        if self.top_k != parked.top_k {
140            return Some("top_k");
141        }
142        if self.top_p_bits != parked.top_p_bits {
143            return Some("top_p");
144        }
145        if self.min_p_bits != parked.min_p_bits {
146            return Some("min_p");
147        }
148        if self.penalty_last_n != parked.penalty_last_n {
149            return Some("penalty_last_n");
150        }
151        if self.penalty_repeat_bits != parked.penalty_repeat_bits {
152            return Some("penalty_repeat");
153        }
154        if self.penalty_freq_bits != parked.penalty_freq_bits {
155            return Some("penalty_freq");
156        }
157        if self.penalty_present_bits != parked.penalty_present_bits {
158            return Some("penalty_present");
159        }
160        None
161    }
162
163    /// THE PRE-LANE PREDICATE, RESTATED (teeth, not production). The spec pool-resume probe
164    /// applied no sampler test at all — it compared prompts and nothing else — so every sampler
165    /// pair was admitted. Restating it here keeps the refusal tests DECISIVE instead of
166    /// tautological: the same pair that `mismatch` names must be admitted by this, or the test is
167    /// asserting against a mechanism that never existed.
168    ///
169    /// It is also what `MEMRA_SPEC_RESUME_SAMPLER=0` selects at runtime (the rollback door and the
170    /// A/B arm the cost measurement needs), so this function is the single definition of "legacy"
171    /// for both the tests and the server.
172    pub fn legacy_admits(&self, _parked: &Self) -> bool {
173        true
174    }
175}
176
177/// Stateful sampler: owns the RNG + the recent-token history (for penalties).
178pub struct Sampler {
179    cfg: SamplerConfig,
180    rng: SplitMix64,
181    history: Vec<u32>, // recently emitted tokens (for penalty window)
182}
183
184impl Sampler {
185    pub fn new(cfg: SamplerConfig) -> Self {
186        let rng = SplitMix64::new(cfg.seed);
187        Sampler {
188            cfg,
189            rng,
190            history: Vec::new(),
191        }
192    }
193
194    pub fn is_greedy(&self) -> bool {
195        self.cfg.temperature <= 0.0
196    }
197    /// Sampled spec in its FASTEST regime: pure temperature, no truncation filters, no
198    /// penalties. Filters and penalties are also distribution-exact under the rejection
199    /// verify (spec.rs applies both symmetrically to draft q and target p), so they remain
200    /// spec-ELIGIBLE — see `spec_eligible` in memra-server's worker, the authoritative
201    /// predicate. What they cost is the in-graph draft chain: the captured sampled draft
202    /// samples from the RAW softmax and can hold neither per-row filter stats nor a varying
203    /// penalty history, so `spec.rs` engages `graph_s` only in this pure-temp regime
204    /// (`pure_temp`) and otherwise falls back to the eager draft chain. This predicate names
205    /// that regime; it is NOT an eligibility test.
206    pub fn is_spec_sampling(&self) -> bool {
207        self.cfg.temperature > 0.0
208            && self.cfg.penalty_repeat == 1.0
209            && self.cfg.penalty_freq == 0.0
210            && self.cfg.penalty_present == 0.0
211            && self.cfg.top_k == 0
212            && self.cfg.top_p >= 1.0
213            && self.cfg.min_p <= 0.0
214    }
215    pub fn top_k(&self) -> usize {
216        self.cfg.top_k
217    }
218    pub fn penalty_last_n(&self) -> usize {
219        self.cfg.penalty_last_n
220    }
221    pub fn penalty_repeat(&self) -> f32 {
222        self.cfg.penalty_repeat
223    }
224    pub fn penalty_freq(&self) -> f32 {
225        self.cfg.penalty_freq
226    }
227    pub fn penalty_present(&self) -> f32 {
228        self.cfg.penalty_present
229    }
230    pub fn top_p(&self) -> f32 {
231        self.cfg.top_p
232    }
233    pub fn min_p(&self) -> f32 {
234        self.cfg.min_p
235    }
236    pub fn temperature(&self) -> f32 {
237        self.cfg.temperature
238    }
239    pub fn seed(&self) -> u64 {
240        self.cfg.seed
241    }
242    /// This sampler's canonical [`SamplerIdentity`] — the whole-session resume predicate's input.
243    pub fn identity(&self) -> SamplerIdentity {
244        SamplerIdentity::of(&self.cfg)
245    }
246
247    /// Record an emitted token so subsequent penalties see it.
248    pub fn accept(&mut self, token: u32) {
249        self.history.push(token);
250    }
251
252    /// Sample the next token id from raw logits [n_vocab]. Does NOT mutate logits in place beyond
253    /// a local copy. Returns the chosen token id. (Caller should `accept()` it afterwards.)
254    pub fn sample(&mut self, logits: &[f32]) -> u32 {
255        // Greedy fast path: argmax over RAW logits (penalties don't change the argmax direction
256        // enough to matter for the reference path; llama greedy is also pre-penalty argmax only
257        // when no penalties set — but to stay correct under penalties we still apply them first).
258        if self.is_greedy()
259            && self.cfg.penalty_repeat == 1.0
260            && self.cfg.penalty_freq == 0.0
261            && self.cfg.penalty_present == 0.0
262        {
263            return argmax_u32(logits);
264        }
265
266        // Work on (id, logit) candidates.
267        let mut cand: Vec<(u32, f32)> = logits
268            .iter()
269            .enumerate()
270            .map(|(i, &l)| (i as u32, l))
271            .collect();
272
273        // 1. Penalties (operate on logits, over the last-n history window).
274        self.apply_penalties(&mut cand);
275
276        // Greedy-with-penalties: argmax after penalties, no sampling.
277        if self.is_greedy() {
278            let mut best = cand[0];
279            for &c in &cand[1..] {
280                if c.1 > best.1 {
281                    best = c;
282                }
283            }
284            return best.0;
285        }
286
287        // 2. Temperature scale.
288        if self.cfg.temperature > 0.0 && self.cfg.temperature != 1.0 {
289            let inv = 1.0 / self.cfg.temperature;
290            for c in cand.iter_mut() {
291                c.1 *= inv;
292            }
293        }
294
295        // 3. top-k: keep the k highest-logit candidates (partial sort by logit desc).
296        if self.cfg.top_k > 0 && self.cfg.top_k < cand.len() {
297            cand.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
298            cand.truncate(self.cfg.top_k);
299        }
300
301        // softmax over the surviving candidates (numerically stable).
302        softmax_inplace(&mut cand);
303
304        // 4. top-p (nucleus): smallest set whose cumulative prob >= top_p. Needs desc-by-prob order.
305        if self.cfg.top_p < 1.0 {
306            cand.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
307            let mut cum = 0.0f32;
308            let mut keep = 0usize;
309            for (i, c) in cand.iter().enumerate() {
310                cum += c.1;
311                keep = i + 1;
312                if cum >= self.cfg.top_p {
313                    break;
314                }
315            }
316            cand.truncate(keep.max(1));
317        }
318
319        // 5. min-p: keep candidates with prob >= min_p * max_prob.
320        if self.cfg.min_p > 0.0 {
321            let maxp = cand.iter().map(|c| c.1).fold(0.0f32, f32::max);
322            let thresh = self.cfg.min_p * maxp;
323            cand.retain(|c| c.1 >= thresh);
324            if cand.is_empty() {
325                return argmax_u32(logits);
326            } // safety
327        }
328
329        // renormalize the surviving probs and draw.
330        let sum: f32 = cand.iter().map(|c| c.1).sum();
331        let r = self.rng.next_f32() * sum;
332        let mut acc = 0.0f32;
333        for c in &cand {
334            acc += c.1;
335            if acc >= r {
336                return c.0;
337            }
338        }
339        cand.last().unwrap().0
340    }
341
342    /// llama.cpp penalty: for each token in the last-n history, repeat-divide/multiply its logit
343    /// and apply frequency*count + presence. (llama-sampler.cpp penalties.)
344    fn apply_penalties(&self, cand: &mut [(u32, f32)]) {
345        let n = self.cfg.penalty_last_n;
346        if n == 0 {
347            return;
348        }
349        if self.cfg.penalty_repeat == 1.0
350            && self.cfg.penalty_freq == 0.0
351            && self.cfg.penalty_present == 0.0
352        {
353            return;
354        }
355        let start = self.history.len().saturating_sub(n);
356        let window = &self.history[start..];
357        if window.is_empty() {
358            return;
359        }
360        // count occurrences in the window
361        use std::collections::HashMap;
362        let mut counts: HashMap<u32, i32> = HashMap::new();
363        for &t in window {
364            *counts.entry(t).or_insert(0) += 1;
365        }
366        for c in cand.iter_mut() {
367            if let Some(&cnt) = counts.get(&c.0) {
368                // repeat: llama divides if logit>0 else multiplies (penalize toward 0)
369                if self.cfg.penalty_repeat != 1.0 {
370                    if c.1 > 0.0 {
371                        c.1 /= self.cfg.penalty_repeat;
372                    } else {
373                        c.1 *= self.cfg.penalty_repeat;
374                    }
375                }
376                c.1 -= cnt as f32 * self.cfg.penalty_freq;
377                c.1 -= self.cfg.penalty_present; // presence: applied once if count>0
378            }
379        }
380    }
381}
382
383fn argmax_u32(logits: &[f32]) -> u32 {
384    let mut best = 0u32;
385    let mut bv = f32::NEG_INFINITY;
386    for (i, &v) in logits.iter().enumerate() {
387        if v > bv {
388            bv = v;
389            best = i as u32;
390        }
391    }
392    best
393}
394
395/// Stable softmax over candidate logits, writing probs back into the logit slot.
396fn softmax_inplace(cand: &mut [(u32, f32)]) {
397    let maxl = cand.iter().map(|c| c.1).fold(f32::NEG_INFINITY, f32::max);
398    let mut sum = 0.0f32;
399    for c in cand.iter_mut() {
400        let e = (c.1 - maxl).exp();
401        c.1 = e;
402        sum += e;
403    }
404    let inv = if sum > 0.0 { 1.0 / sum } else { 0.0 };
405    for c in cand.iter_mut() {
406        c.1 *= inv;
407    }
408}
409
410/// SplitMix64 — deterministic seedable RNG (so a fixed seed reproduces the token stream for the
411/// validation gate). Not crypto; fine for sampling.
412struct SplitMix64 {
413    state: u64,
414}
415impl SplitMix64 {
416    fn new(seed: u64) -> Self {
417        SplitMix64 {
418            state: seed.wrapping_add(0x9E3779B97F4A7C15),
419        }
420    }
421    fn next_u64(&mut self) -> u64 {
422        self.state = self.state.wrapping_add(0x9E3779B97F4A7C15);
423        let mut z = self.state;
424        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
425        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
426        z ^ (z >> 31)
427    }
428    /// uniform f32 in [0,1).
429    fn next_f32(&mut self) -> f32 {
430        // top 24 bits -> [0,1)
431        ((self.next_u64() >> 40) as f32) / (1u32 << 24) as f32
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn greedy_is_argmax() {
441        let mut s = Sampler::new(SamplerConfig::default()); // temp 0
442        let logits = vec![0.1, 5.0, 2.0, -1.0];
443        assert_eq!(s.sample(&logits), 1);
444    }
445
446    #[test]
447    fn temp_sampling_deterministic_with_seed() {
448        let cfg = SamplerConfig {
449            temperature: 1.0,
450            seed: 42,
451            ..Default::default()
452        };
453        let logits = vec![1.0, 2.0, 3.0, 0.5];
454        let a = Sampler::new(cfg.clone()).sample(&logits);
455        let b = Sampler::new(cfg).sample(&logits);
456        assert_eq!(a, b, "same seed must reproduce the draw");
457        assert!(a < 4);
458    }
459
460    #[test]
461    fn top_k_one_is_argmax() {
462        let cfg = SamplerConfig {
463            temperature: 1.0,
464            top_k: 1,
465            seed: 7,
466            ..Default::default()
467        };
468        let logits = vec![0.1, 5.0, 2.0, -1.0];
469        assert_eq!(
470            Sampler::new(cfg).sample(&logits),
471            1,
472            "top_k=1 collapses to argmax"
473        );
474    }
475
476    #[test]
477    fn min_p_keeps_only_high_prob() {
478        // logit 10 dominates; min_p 0.5 should drop the rest -> always pick id 2.
479        let cfg = SamplerConfig {
480            temperature: 1.0,
481            min_p: 0.5,
482            seed: 3,
483            ..Default::default()
484        };
485        let logits = vec![0.0, 0.0, 10.0, 0.0];
486        for _ in 0..16 {
487            assert_eq!(Sampler::new(cfg.clone()).sample(&logits), 2);
488        }
489    }
490
491    #[test]
492    fn repeat_penalty_suppresses_recent() {
493        // greedy + heavy repeat penalty: id 1 is argmax but recently emitted -> should drop it.
494        let mut cfg = SamplerConfig::default();
495        cfg.penalty_last_n = 8;
496        cfg.penalty_repeat = 100.0;
497        let mut s = Sampler::new(cfg);
498        s.accept(1); // 1 was just emitted
499        let logits = vec![4.0, 5.0, 4.5, 1.0]; // raw argmax = 1
500        let got = s.sample(&logits);
501        assert_ne!(
502            got, 1,
503            "recent token must be penalized out of greedy argmax"
504        );
505        assert_eq!(got, 2, "next-highest after penalizing 1");
506    }
507}
508
509/// SESSION-RESUME SAMPLER PREDICATE teeth (lane/session-resume-sampler-predicate-20260820).
510/// CPU-only, no GPU: the predicate is a pure function, so its whole contract is testable here and
511/// a regression cannot hide behind "needs a card".
512///
513/// TEETH BOTH DIRECTIONS is the point. Every refusal test also asserts that `legacy_admits`
514/// ADMITS the same pair — the pre-lane probe compared prompts and never samplers — so the test is
515/// decisive (it fails on the old code) rather than tautological (passing because the pair was
516/// never resumable for some other reason).
517#[cfg(test)]
518mod resume_sampler_predicate_tests {
519    use super::*;
520
521    /// The vendor-default sampled shape the flip makes the majority of traffic.
522    fn vendor() -> SamplerConfig {
523        SamplerConfig {
524            temperature: 0.7,
525            top_k: 20,
526            top_p: 0.95,
527            seed: 20260820,
528            ..Default::default()
529        }
530    }
531
532    /// Today's pure-temp shape — the one that parks a `graph_s`.
533    fn pure_temp() -> SamplerConfig {
534        SamplerConfig {
535            temperature: 0.7,
536            seed: 20260820,
537            ..Default::default()
538        }
539    }
540
541    fn id(cfg: &SamplerConfig) -> SamplerIdentity {
542        SamplerIdentity::of(cfg)
543    }
544
545    // ---- direction 1: a SAME-sampler resume still resumes (no regression) ----
546
547    #[test]
548    fn identical_sampler_resumes() {
549        for cfg in [pure_temp(), vendor(), SamplerConfig::default()] {
550            assert_eq!(
551                id(&cfg).mismatch(&id(&cfg)),
552                None,
553                "a request must resume a session its own sampler shaped: {cfg:?}"
554            );
555        }
556    }
557
558    #[test]
559    fn disabled_sentinels_are_the_same_program() {
560        // top_p >= 1.0, min_p <= 0.0, top_k == 0 all mean OFF; a client that spells OFF
561        // differently on turn 2 must not lose its cache.
562        let a = SamplerConfig {
563            temperature: 0.7,
564            top_p: 1.0,
565            min_p: 0.0,
566            ..Default::default()
567        };
568        let b = SamplerConfig {
569            temperature: 0.7,
570            top_p: 1.5,
571            min_p: -1.0,
572            ..Default::default()
573        };
574        assert_eq!(id(&a).mismatch(&id(&b)), None, "off spelled two ways");
575    }
576
577    #[test]
578    fn greedy_temperature_encodings_are_one_program() {
579        let a = SamplerConfig {
580            temperature: 0.0,
581            ..Default::default()
582        };
583        let b = SamplerConfig {
584            temperature: -1.0,
585            ..Default::default()
586        };
587        assert_eq!(id(&a).mismatch(&id(&b)), None, "temp<=0 is one regime");
588    }
589
590    #[test]
591    fn neutral_penalty_coefficients_equal_penalties_absent() {
592        // penalty_last_n set but every coefficient neutral == `pen_on == false` in spec.rs.
593        let a = SamplerConfig {
594            temperature: 0.7,
595            penalty_last_n: 64,
596            penalty_repeat: 1.0,
597            penalty_freq: 0.0,
598            penalty_present: 0.0,
599            ..Default::default()
600        };
601        let b = SamplerConfig {
602            temperature: 0.7,
603            penalty_last_n: 0,
604            ..Default::default()
605        };
606        assert_eq!(
607            id(&a).mismatch(&id(&b)),
608            None,
609            "an inert penalty window is not a penalty change"
610        );
611    }
612
613    // ---- direction 2: a sampler-DIFFERING resume refuses, and names the field ----
614
615    #[test]
616    fn the_reproduced_collision_pair_refuses_and_names_a_filter() {
617        // The exact pair the predecessor reproduced on a live server: turn 1 pure-temp parks,
618        // turn 2 adds top_p 0.95 / top_k 20 and resumes. Same seed, same temperature.
619        let parked = id(&pure_temp());
620        let incoming = id(&vendor());
621        let field = incoming
622            .mismatch(&parked)
623            .expect("the reproduced collision pair must refuse");
624        assert_eq!(field, "top_k", "coarsest-first order names top_k here");
625        // DECISIVE: the pre-lane probe admitted exactly this pair.
626        assert!(
627            incoming.legacy_admits(&parked),
628            "legacy must admit the collision pair, or this test proves nothing"
629        );
630    }
631
632    #[test]
633    fn every_compared_field_refuses_on_its_own_and_names_itself() {
634        let base = pure_temp();
635        // A penalized base, so the three coefficients can each move ALONE: with penalties off on
636        // the parked side, turning any of them on also moves `penalty_last_n`, and coarsest-first
637        // order would (correctly) name the window instead of the coefficient.
638        let pen_base = SamplerConfig {
639            penalty_last_n: 64,
640            penalty_repeat: 1.1,
641            penalty_freq: 0.5,
642            penalty_present: 0.5,
643            ..base.clone()
644        };
645        // (field, parked, incoming) — exactly one canonical field differs in each row.
646        let cases: [(&str, SamplerConfig, SamplerConfig); 9] = [
647            (
648                "regime",
649                base.clone(),
650                SamplerConfig {
651                    temperature: 0.0,
652                    ..base.clone()
653                },
654            ),
655            (
656                "temperature",
657                base.clone(),
658                SamplerConfig {
659                    temperature: 0.8,
660                    ..base.clone()
661                },
662            ),
663            (
664                "top_k",
665                base.clone(),
666                SamplerConfig {
667                    top_k: 20,
668                    ..base.clone()
669                },
670            ),
671            (
672                "top_p",
673                base.clone(),
674                SamplerConfig {
675                    top_p: 0.95,
676                    ..base.clone()
677                },
678            ),
679            (
680                "min_p",
681                base.clone(),
682                SamplerConfig {
683                    min_p: 0.05,
684                    ..base.clone()
685                },
686            ),
687            (
688                "penalty_last_n",
689                pen_base.clone(),
690                SamplerConfig {
691                    penalty_last_n: 128,
692                    ..pen_base.clone()
693                },
694            ),
695            (
696                "penalty_repeat",
697                pen_base.clone(),
698                SamplerConfig {
699                    penalty_repeat: 1.2,
700                    ..pen_base.clone()
701                },
702            ),
703            (
704                "penalty_freq",
705                pen_base.clone(),
706                SamplerConfig {
707                    penalty_freq: 0.6,
708                    ..pen_base.clone()
709                },
710            ),
711            (
712                "penalty_present",
713                pen_base.clone(),
714                SamplerConfig {
715                    penalty_present: 0.6,
716                    ..pen_base.clone()
717                },
718            ),
719        ];
720        for (expect, parked_cfg, cfg) in cases {
721            let parked = id(&parked_cfg);
722            let incoming = id(&cfg);
723            assert_eq!(
724                incoming.mismatch(&parked),
725                Some(expect),
726                "changing {expect} alone must refuse and name {expect} ({cfg:?})"
727            );
728            assert!(
729                incoming.legacy_admits(&parked),
730                "legacy must admit the {expect} change, or the refusal test is tautological"
731            );
732        }
733        // Turning penalties ON from an unpenalized parked session is a `penalty_last_n` refusal —
734        // the coarsest true statement about that pair, asserted so the order is pinned.
735        assert_eq!(
736            id(&pen_base).mismatch(&id(&base)),
737            Some("penalty_last_n"),
738            "penalties on vs off is named at the window, not at a coefficient"
739        );
740    }
741
742    #[test]
743    fn greedy_to_sampled_and_back_both_refuse_as_regime() {
744        let g = id(&SamplerConfig::default());
745        let s = id(&pure_temp());
746        assert_eq!(s.mismatch(&g), Some("regime"));
747        assert_eq!(g.mismatch(&s), Some("regime"));
748    }
749
750    // ---- the seed decision, pinned so it cannot change silently ----
751
752    #[test]
753    fn seed_alone_does_not_refuse() {
754        // DELIBERATE (see SamplerIdentity::mismatch): the draft graph is re-keyed on seed by
755        // SampledGraphKey and the session's Philox counters are counter-based, so a changed seed
756        // is sound — and comparing it would refuse every seed-omitting sampled conversation,
757        // because an omitted seed draws fresh per-request entropy.
758        let a = pure_temp();
759        let b = SamplerConfig {
760            seed: 999,
761            ..a.clone()
762        };
763        assert_eq!(
764            id(&a).mismatch(&id(&b)),
765            None,
766            "seed is carried but not compared"
767        );
768        assert_ne!(id(&a).seed(), id(&b).seed(), "the seed is still recorded");
769    }
770
771    #[test]
772    fn mismatch_is_symmetric_and_identity_is_an_equivalence() {
773        let a = id(&pure_temp());
774        let b = id(&vendor());
775        assert_eq!(a.mismatch(&b).is_some(), b.mismatch(&a).is_some());
776        assert_eq!(a.mismatch(&a), None);
777        assert_eq!(b.mismatch(&b), None);
778    }
779}