Skip to main content

ferrum_interfaces/
sampler.rs

1//! Sampling and logits processing interfaces
2//!
3//! This module provides abstractions for sampling tokens from model outputs,
4//! including various sampling strategies and logits processors. These are
5//! completely separate from model execution to allow for flexible composition.
6
7use ferrum_types::{Result, SamplingParams, TokenId};
8use rand::{RngCore, SeedableRng};
9use rand_chacha::ChaCha12Rng;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fmt;
13
14/// Stable request-local RNG used by every product sampling path.
15///
16/// `rand::rngs::StdRng` intentionally does not promise a portable algorithm.
17/// Naming the generator and seed expansion here makes seeded request replay an
18/// explicit Ferrum contract instead of an incidental dependency choice.
19pub const SAMPLING_RNG_ALGORITHM_ID: &str = "chacha12-rand-core-pcg32-u64-v1";
20
21#[derive(Clone, Debug)]
22pub struct SamplingRng {
23    inner: ChaCha12Rng,
24}
25
26impl SamplingRng {
27    pub fn seeded(seed: u64) -> Self {
28        Self {
29            inner: ChaCha12Rng::seed_from_u64(seed),
30        }
31    }
32
33    pub fn from_seed_bytes(seed: [u8; 32]) -> Self {
34        Self {
35            inner: ChaCha12Rng::from_seed(seed),
36        }
37    }
38
39    pub fn from_entropy() -> Self {
40        let mut entropy = rand::rng();
41        Self {
42            inner: ChaCha12Rng::from_rng(&mut entropy),
43        }
44    }
45
46    pub const fn algorithm_id() -> &'static str {
47        SAMPLING_RNG_ALGORITHM_ID
48    }
49}
50
51impl RngCore for SamplingRng {
52    fn next_u32(&mut self) -> u32 {
53        self.inner.next_u32()
54    }
55
56    fn next_u64(&mut self) -> u64 {
57        self.inner.next_u64()
58    }
59
60    fn fill_bytes(&mut self, dest: &mut [u8]) {
61        self.inner.fill_bytes(dest);
62    }
63}
64
65/// Sampling context passed to logits processors and samplers
66#[derive(Debug)]
67pub struct SamplingContext<'a> {
68    /// Current generation step (0-based)
69    pub step: usize,
70    /// Request-specific sampling parameters
71    pub sampling_params: &'a SamplingParams,
72    /// Current logits (mutable for processing)
73    pub logits: &'a mut [f32],
74    /// Previous token IDs in sequence  
75    pub previous_tokens: &'a [TokenId],
76    /// Token frequencies for repetition penalty
77    pub token_frequencies: &'a HashMap<TokenId, usize>,
78    /// Vocabulary size
79    pub vocab_size: usize,
80    /// Additional metadata
81    pub metadata: HashMap<String, f32>,
82}
83
84impl<'a> SamplingContext<'a> {
85    /// Create new sampling context
86    pub fn new(
87        step: usize,
88        sampling_params: &'a SamplingParams,
89        logits: &'a mut [f32],
90        previous_tokens: &'a [TokenId],
91        token_frequencies: &'a HashMap<TokenId, usize>,
92        vocab_size: usize,
93    ) -> Self {
94        Self {
95            step,
96            sampling_params,
97            logits,
98            previous_tokens,
99            token_frequencies,
100            vocab_size,
101            metadata: HashMap::new(),
102        }
103    }
104
105    /// Get logit value for specific token
106    pub fn get_logit(&self, token_id: TokenId) -> Option<f32> {
107        if usize::from(token_id) < self.logits.len() {
108            Some(self.logits[usize::from(token_id)])
109        } else {
110            None
111        }
112    }
113
114    /// Set logit value for specific token
115    pub fn set_logit(&mut self, token_id: TokenId, value: f32) -> bool {
116        if usize::from(token_id) < self.logits.len() {
117            self.logits[usize::from(token_id)] = value;
118            true
119        } else {
120            false
121        }
122    }
123
124    /// Mask (set to negative infinity) specific tokens
125    pub fn mask_tokens(&mut self, token_ids: &[TokenId]) {
126        for &token_id in token_ids {
127            if usize::from(token_id) < self.logits.len() {
128                self.logits[usize::from(token_id)] = f32::NEG_INFINITY;
129            }
130        }
131    }
132}
133
134/// Logits processor trait for modifying raw model outputs
135pub trait LogitsProcessor: Send + Sync {
136    /// Process logits in-place
137    fn process(&self, ctx: &mut SamplingContext) -> Result<()>;
138
139    /// Get processor name for debugging/logging
140    fn name(&self) -> &str;
141
142    /// Whether this processor should be applied before others
143    fn priority(&self) -> ProcessorPriority {
144        ProcessorPriority::Normal
145    }
146}
147
148/// Priority levels for logits processors
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
150pub enum ProcessorPriority {
151    /// Applied first (e.g., hard constraints, token masking)
152    High = 3,
153    /// Normal processing order
154    Normal = 2,
155    /// Applied later (e.g., temperature scaling)
156    Low = 1,
157}
158
159/// Token sampler trait for selecting next token from processed logits
160pub trait Sampler: Send + Sync {
161    /// Sample next token from logits
162    fn sample(&self, logits: &[f32], rng: &mut dyn RngCore) -> Result<TokenId>;
163
164    /// Sample with additional context (default implementation ignores context)
165    fn sample_with_context(&self, ctx: &SamplingContext, rng: &mut dyn RngCore) -> Result<TokenId> {
166        self.sample(ctx.logits, rng)
167    }
168
169    /// Get sampler name
170    fn name(&self) -> &str;
171
172    /// Whether this sampler is deterministic
173    fn is_deterministic(&self) -> bool;
174}
175
176/// Multi-sample capability for beam search and parallel sampling
177pub trait MultiSampler: Sampler {
178    /// Sample multiple tokens at once
179    fn sample_multiple(
180        &self,
181        logits: &[f32],
182        num_samples: usize,
183        rng: &mut dyn RngCore,
184    ) -> Result<Vec<TokenId>>;
185
186    /// Sample with probabilities for each token
187    fn sample_with_probabilities(
188        &self,
189        logits: &[f32],
190        rng: &mut dyn RngCore,
191    ) -> Result<(TokenId, Vec<f32>)>;
192}
193
194/// Logits processor chain for composing multiple processors
195pub struct LogitsProcessorChain {
196    processors: Vec<Box<dyn LogitsProcessor>>,
197}
198
199impl LogitsProcessorChain {
200    /// Create new processor chain
201    pub fn new() -> Self {
202        Self {
203            processors: Vec::new(),
204        }
205    }
206
207    /// Add processor to chain
208    pub fn add_processor(mut self, processor: Box<dyn LogitsProcessor>) -> Self {
209        self.processors.push(processor);
210        // Sort by priority (high to low)
211        self.processors
212            .sort_by(|a, b| b.priority().cmp(&a.priority()));
213        self
214    }
215
216    /// Process logits through entire chain
217    pub fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
218        for processor in &self.processors {
219            processor.process(ctx)?;
220        }
221        Ok(())
222    }
223
224    /// Get all processor names in order
225    pub fn processor_names(&self) -> Vec<&str> {
226        self.processors.iter().map(|p| p.name()).collect()
227    }
228
229    /// Whether raw logits can be sampled without bypassing a processor.
230    pub fn is_empty(&self) -> bool {
231        self.processors.is_empty()
232    }
233}
234
235impl fmt::Debug for LogitsProcessorChain {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        f.debug_list()
238            .entries(self.processors.iter().map(|processor| processor.name()))
239            .finish()
240    }
241}
242
243impl Default for LogitsProcessorChain {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249/// Common logits processors
250
251/// Temperature scaling processor
252pub struct TemperatureProcessor {
253    pub temperature: f32,
254}
255
256impl TemperatureProcessor {
257    pub fn new(temperature: f32) -> Self {
258        Self { temperature }
259    }
260}
261
262impl LogitsProcessor for TemperatureProcessor {
263    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
264        if self.temperature > 0.0 && self.temperature != 1.0 {
265            for logit in ctx.logits.iter_mut() {
266                *logit /= self.temperature;
267            }
268        }
269        Ok(())
270    }
271
272    fn name(&self) -> &str {
273        "temperature"
274    }
275
276    fn priority(&self) -> ProcessorPriority {
277        // Penalties change raw logits first. Temperature must run before
278        // probability-relative filters such as min-p and top-p.
279        ProcessorPriority::Normal
280    }
281}
282
283/// Top-k filtering processor
284pub struct TopKProcessor {
285    pub k: usize,
286}
287
288impl TopKProcessor {
289    pub fn new(k: usize) -> Self {
290        Self { k }
291    }
292}
293
294impl LogitsProcessor for TopKProcessor {
295    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
296        if self.k > 0 && self.k < ctx.logits.len() {
297            // Find k-th largest logit
298            let mut indices: Vec<usize> = (0..ctx.logits.len()).collect();
299            indices.sort_by(|&a, &b| {
300                ctx.logits[b]
301                    .partial_cmp(&ctx.logits[a])
302                    .unwrap_or(std::cmp::Ordering::Equal)
303            });
304
305            let threshold = ctx.logits[indices[self.k - 1]];
306
307            // Mask tokens below threshold
308            for logit in ctx.logits.iter_mut() {
309                if *logit < threshold {
310                    *logit = f32::NEG_INFINITY;
311                }
312            }
313        }
314        Ok(())
315    }
316
317    fn name(&self) -> &str {
318        "top_k"
319    }
320
321    fn priority(&self) -> ProcessorPriority {
322        ProcessorPriority::Low
323    }
324}
325
326/// Top-p (nucleus) filtering processor
327pub struct TopPProcessor {
328    pub p: f32,
329}
330
331impl TopPProcessor {
332    pub fn new(p: f32) -> Self {
333        Self { p }
334    }
335}
336
337impl LogitsProcessor for TopPProcessor {
338    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
339        if self.p < 1.0 && self.p > 0.0 {
340            // A preceding top-k/min-p processor may already have reduced a
341            // full vocabulary to a small finite set. Sorting only that set
342            // keeps the common combined path proportional to its candidates.
343            let mut candidates = ctx
344                .logits
345                .iter()
346                .copied()
347                .enumerate()
348                .filter(|(_, logit)| logit.is_finite())
349                .collect::<Vec<_>>();
350            if candidates.is_empty() {
351                return Ok(());
352            }
353
354            candidates.sort_by(|(left_idx, left), (right_idx, right)| {
355                right.total_cmp(left).then_with(|| left_idx.cmp(right_idx))
356            });
357
358            let max_logit = candidates[0].1;
359            let sum = candidates
360                .iter()
361                .map(|(_, logit)| (*logit - max_logit).exp())
362                .sum::<f32>();
363            let mut cum_prob = 0.0;
364            let mut cutoff_idx = candidates.len();
365            for (i, (_, logit)) in candidates.iter().enumerate() {
366                cum_prob += (*logit - max_logit).exp() / sum;
367                if cum_prob >= self.p {
368                    cutoff_idx = i + 1;
369                    break;
370                }
371            }
372
373            for (idx, _) in candidates.into_iter().skip(cutoff_idx) {
374                ctx.logits[idx] = f32::NEG_INFINITY;
375            }
376        }
377        Ok(())
378    }
379
380    fn name(&self) -> &str {
381        "top_p"
382    }
383
384    fn priority(&self) -> ProcessorPriority {
385        ProcessorPriority::Low
386    }
387}
388
389/// Minimum-probability filtering processor.
390///
391/// A token remains eligible when its probability is at least `min_p` times
392/// the most likely token's probability. In logit space the equivalent
393/// threshold is `max_logit + ln(min_p)`, so this needs no softmax allocation.
394pub struct MinPProcessor {
395    pub min_p: f32,
396}
397
398impl MinPProcessor {
399    pub fn new(min_p: f32) -> Self {
400        Self { min_p }
401    }
402}
403
404impl LogitsProcessor for MinPProcessor {
405    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
406        if self.min_p > 0.0 && self.min_p <= 1.0 {
407            let max_logit = ctx.logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
408            let threshold = max_logit + self.min_p.ln();
409            for logit in ctx.logits.iter_mut() {
410                if *logit < threshold {
411                    *logit = f32::NEG_INFINITY;
412                }
413            }
414        }
415        Ok(())
416    }
417
418    fn name(&self) -> &str {
419        "min_p"
420    }
421
422    fn priority(&self) -> ProcessorPriority {
423        ProcessorPriority::Low
424    }
425}
426
427/// Repetition penalty processor
428pub struct RepetitionPenaltyProcessor {
429    pub penalty: f32,
430}
431
432impl RepetitionPenaltyProcessor {
433    pub fn new(penalty: f32) -> Self {
434        Self { penalty }
435    }
436}
437
438impl LogitsProcessor for RepetitionPenaltyProcessor {
439    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
440        if self.penalty != 1.0 {
441            for &token_id in ctx.token_frequencies.keys() {
442                if usize::from(token_id) >= ctx.logits.len() {
443                    continue;
444                }
445                let idx = usize::from(token_id);
446                let current_logit = ctx.logits[idx];
447                if current_logit > 0.0 {
448                    ctx.logits[idx] = current_logit / self.penalty;
449                } else {
450                    ctx.logits[idx] = current_logit * self.penalty;
451                }
452            }
453        }
454        Ok(())
455    }
456
457    fn name(&self) -> &str {
458        "repetition_penalty"
459    }
460
461    fn priority(&self) -> ProcessorPriority {
462        ProcessorPriority::High // Apply penalties early
463    }
464}
465
466/// OpenAI-compatible additive penalties over generated-token counts.
467///
468/// Repetition penalty is multiplicative and remains a separate processor.
469/// Presence and frequency penalties are additive and are applied afterwards:
470/// `logit -= presence * seen + frequency * count`.
471pub struct PresenceFrequencyPenaltyProcessor {
472    pub presence_penalty: f32,
473    pub frequency_penalty: f32,
474}
475
476impl PresenceFrequencyPenaltyProcessor {
477    pub fn new(presence_penalty: f32, frequency_penalty: f32) -> Self {
478        Self {
479            presence_penalty,
480            frequency_penalty,
481        }
482    }
483}
484
485impl LogitsProcessor for PresenceFrequencyPenaltyProcessor {
486    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
487        if self.presence_penalty == 0.0 && self.frequency_penalty == 0.0 {
488            return Ok(());
489        }
490        for (&token_id, &count) in ctx.token_frequencies {
491            let idx = usize::from(token_id);
492            if idx >= ctx.logits.len() || count == 0 {
493                continue;
494            }
495            ctx.logits[idx] -= self.presence_penalty + self.frequency_penalty * count as f32;
496        }
497        Ok(())
498    }
499
500    fn name(&self) -> &str {
501        "presence_frequency_penalty"
502    }
503
504    fn priority(&self) -> ProcessorPriority {
505        ProcessorPriority::High
506    }
507}
508
509/// Common samplers
510
511/// Greedy sampler (always picks highest probability token)
512pub struct GreedySampler;
513
514impl Sampler for GreedySampler {
515    fn sample(&self, logits: &[f32], _rng: &mut dyn RngCore) -> Result<TokenId> {
516        let max_idx = logits
517            .iter()
518            .enumerate()
519            .filter(|(_, logit)| logit.is_finite())
520            .reduce(|best, candidate| match candidate.1.total_cmp(best.1) {
521                std::cmp::Ordering::Greater => candidate,
522                std::cmp::Ordering::Equal if candidate.0 < best.0 => candidate,
523                _ => best,
524            })
525            .map(|(idx, _)| idx)
526            .ok_or_else(|| {
527                ferrum_types::FerrumError::backend("No finite logits available for sampling")
528            })?;
529
530        Ok(TokenId::new(max_idx as u32))
531    }
532
533    fn name(&self) -> &str {
534        "greedy"
535    }
536
537    fn is_deterministic(&self) -> bool {
538        true
539    }
540}
541
542#[cfg(test)]
543mod greedy_sampler_tests {
544    use super::{GreedySampler, Sampler, SamplingRng};
545
546    #[test]
547    fn ties_choose_the_lowest_token_id() {
548        let mut rng = SamplingRng::seeded(1);
549        let token = GreedySampler
550            .sample(&[-1.0, 4.0, 4.0, f32::NAN], &mut rng)
551            .unwrap();
552        assert_eq!(token.get(), 1);
553    }
554
555    #[test]
556    fn non_finite_logits_are_never_selected() {
557        let mut rng = SamplingRng::seeded(1);
558        let token = GreedySampler
559            .sample(&[f32::NAN, f32::INFINITY, -2.0], &mut rng)
560            .unwrap();
561        assert_eq!(token.get(), 2);
562        assert!(GreedySampler
563            .sample(&[f32::NAN, f32::INFINITY], &mut rng)
564            .is_err());
565    }
566}
567
568/// Multinomial sampler for probabilistic sampling
569pub struct MultinomialSampler;
570
571impl Sampler for MultinomialSampler {
572    fn sample(&self, logits: &[f32], rng: &mut dyn RngCore) -> Result<TokenId> {
573        // Convert logits to probabilities
574        let max_logit = logits.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
575
576        let mut probs: Vec<f32> = logits
577            .iter()
578            .map(|&logit| {
579                if logit.is_finite() && logit > f32::NEG_INFINITY {
580                    (logit - max_logit).exp()
581                } else {
582                    0.0
583                }
584            })
585            .collect();
586
587        let sum: f32 = probs.iter().sum();
588        if sum <= 0.0 {
589            return Err(ferrum_types::FerrumError::backend(
590                "No valid tokens for sampling",
591            ));
592        }
593
594        for prob in probs.iter_mut() {
595            *prob /= sum;
596        }
597
598        // Sample from categorical distribution
599        let threshold = rng.next_u32() as f32 / u32::MAX as f32;
600        let mut cumulative = 0.0;
601
602        for (idx, prob) in probs.iter().enumerate() {
603            cumulative += prob;
604            if cumulative >= threshold {
605                return Ok(TokenId::new(idx as u32));
606            }
607        }
608
609        // Fallback to last token (shouldn't happen with proper normalization)
610        Ok(TokenId::new((probs.len() - 1) as u32))
611    }
612
613    fn name(&self) -> &str {
614        "multinomial"
615    }
616
617    fn is_deterministic(&self) -> bool {
618        false
619    }
620}
621
622/// Sampling configuration builder
623pub struct SamplingConfigBuilder {
624    processors: Vec<Box<dyn LogitsProcessor>>,
625    sampler: Option<Box<dyn Sampler>>,
626}
627
628impl SamplingConfigBuilder {
629    /// Create new builder
630    pub fn new() -> Self {
631        Self {
632            processors: Vec::new(),
633            sampler: None,
634        }
635    }
636
637    /// Add temperature scaling
638    pub fn with_temperature(mut self, temperature: f32) -> Self {
639        if temperature > 0.0 && temperature != 1.0 {
640            self.processors
641                .push(Box::new(TemperatureProcessor::new(temperature)));
642        }
643        self
644    }
645
646    /// Add top-k filtering
647    pub fn with_top_k(mut self, k: usize) -> Self {
648        if k > 0 {
649            self.processors.push(Box::new(TopKProcessor::new(k)));
650        }
651        self
652    }
653
654    /// Add top-p filtering
655    pub fn with_top_p(mut self, p: f32) -> Self {
656        if p > 0.0 && p < 1.0 {
657            self.processors.push(Box::new(TopPProcessor::new(p)));
658        }
659        self
660    }
661
662    /// Add minimum-probability filtering.
663    pub fn with_min_p(mut self, min_p: f32) -> Self {
664        if min_p > 0.0 && min_p <= 1.0 {
665            self.processors.push(Box::new(MinPProcessor::new(min_p)));
666        }
667        self
668    }
669
670    /// Add repetition penalty
671    pub fn with_repetition_penalty(mut self, penalty: f32) -> Self {
672        if penalty != 1.0 {
673            self.processors
674                .push(Box::new(RepetitionPenaltyProcessor::new(penalty)));
675        }
676        self
677    }
678
679    /// Add OpenAI-compatible presence and frequency penalties.
680    pub fn with_presence_frequency_penalty(
681        mut self,
682        presence_penalty: f32,
683        frequency_penalty: f32,
684    ) -> Self {
685        if presence_penalty != 0.0 || frequency_penalty != 0.0 {
686            self.processors
687                .push(Box::new(PresenceFrequencyPenaltyProcessor::new(
688                    presence_penalty,
689                    frequency_penalty,
690                )));
691        }
692        self
693    }
694
695    /// Set sampler (greedy vs multinomial)
696    pub fn with_sampler(mut self, sampler: Box<dyn Sampler>) -> Self {
697        self.sampler = Some(sampler);
698        self
699    }
700
701    /// Build sampling configuration
702    pub fn build(self) -> SamplingConfig {
703        let mut chain = LogitsProcessorChain::new();
704        for processor in self.processors {
705            chain = chain.add_processor(processor);
706        }
707
708        let sampler = self.sampler.unwrap_or_else(|| Box::new(MultinomialSampler));
709
710        SamplingConfig {
711            processor_chain: chain,
712            sampler,
713        }
714    }
715}
716
717impl Default for SamplingConfigBuilder {
718    fn default() -> Self {
719        Self::new()
720    }
721}
722
723/// Complete sampling configuration
724pub struct SamplingConfig {
725    pub processor_chain: LogitsProcessorChain,
726    pub sampler: Box<dyn Sampler>,
727}
728
729impl fmt::Debug for SamplingConfig {
730    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
731        f.debug_struct("SamplingConfig")
732            .field("processor_chain", &self.processor_chain)
733            .field("sampler", &self.sampler.name())
734            .finish()
735    }
736}
737
738impl SamplingConfig {
739    /// Create from sampling parameters
740    pub fn from_params(params: &SamplingParams) -> Self {
741        let mut builder = SamplingConfigBuilder::new()
742            .with_temperature(params.temperature)
743            .with_repetition_penalty(params.repetition_penalty)
744            .with_presence_frequency_penalty(params.presence_penalty, params.frequency_penalty);
745
746        if let Some(min_p) = params.min_p {
747            builder = builder.with_min_p(min_p);
748        }
749
750        if let Some(top_k) = params.top_k {
751            builder = builder.with_top_k(top_k);
752        }
753
754        if params.top_p < 1.0 {
755            builder = builder.with_top_p(params.top_p);
756        }
757
758        // Choose sampler based on temperature
759        let sampler: Box<dyn Sampler> = if params.temperature == 0.0 {
760            Box::new(GreedySampler)
761        } else {
762            Box::new(MultinomialSampler)
763        };
764
765        builder.with_sampler(sampler).build()
766    }
767
768    /// Whether the current plan is exactly raw greedy argmax.
769    ///
770    /// The legacy speculative runner only represents temperature and drafts
771    /// with argmax. Any logits processor would otherwise be silently skipped.
772    pub fn supports_raw_greedy_speculation(&self) -> bool {
773        self.sampler.is_deterministic() && self.processor_chain.is_empty()
774    }
775
776    /// Process logits and sample token
777    pub fn sample(&self, mut ctx: SamplingContext, rng: &mut dyn RngCore) -> Result<TokenId> {
778        // Apply all logits processors
779        self.processor_chain.process(&mut ctx)?;
780
781        // Sample token
782        self.sampler.sample_with_context(&ctx, rng)
783    }
784}
785
786/// Sampling statistics for monitoring
787#[derive(Debug, Clone, Serialize, Deserialize)]
788pub struct SamplingStats {
789    /// Total sampling operations
790    pub total_samples: u64,
791    /// Average sampling time in microseconds
792    pub avg_sample_time_us: f64,
793    /// Distribution of sampled tokens
794    pub token_distribution: HashMap<TokenId, u64>,
795    /// Effective temperature (entropy-based measure)
796    pub effective_temperature: f32,
797    /// Processor execution times
798    pub processor_times: HashMap<String, f64>,
799}