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            let threshold = if ctx.logits.iter().any(|logit| logit.is_nan()) {
298                // Preserve the legacy stable-sort behavior for NaNs. Its
299                // comparator is not a total ordering, so it cannot be used
300                // with selection on these inputs.
301                let mut indices: Vec<usize> = (0..ctx.logits.len()).collect();
302                indices.sort_by(|&a, &b| {
303                    ctx.logits[b]
304                        .partial_cmp(&ctx.logits[a])
305                        .unwrap_or(std::cmp::Ordering::Equal)
306                });
307                ctx.logits[indices[self.k - 1]]
308            } else {
309                // Only the k-th value is needed. Partition a scratch copy so
310                // the sampler still sees logits in their original token order.
311                let mut candidates = ctx.logits.to_vec();
312                let (_, threshold, _) = candidates.select_nth_unstable_by(self.k - 1, |a, b| {
313                    b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
314                });
315                *threshold
316            };
317
318            // Retain every threshold tie, including both signs of zero.
319            for logit in ctx.logits.iter_mut() {
320                if *logit < threshold {
321                    *logit = f32::NEG_INFINITY;
322                }
323            }
324        }
325        Ok(())
326    }
327
328    fn name(&self) -> &str {
329        "top_k"
330    }
331
332    fn priority(&self) -> ProcessorPriority {
333        ProcessorPriority::Low
334    }
335}
336
337/// Top-p (nucleus) filtering processor
338pub struct TopPProcessor {
339    pub p: f32,
340}
341
342impl TopPProcessor {
343    pub fn new(p: f32) -> Self {
344        Self { p }
345    }
346}
347
348impl LogitsProcessor for TopPProcessor {
349    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
350        if self.p < 1.0 && self.p > 0.0 {
351            // A preceding top-k/min-p processor may already have reduced a
352            // full vocabulary to a small finite set. Sorting only that set
353            // keeps the common combined path proportional to its candidates.
354            let mut candidates = ctx
355                .logits
356                .iter()
357                .copied()
358                .enumerate()
359                .filter(|(_, logit)| logit.is_finite())
360                .collect::<Vec<_>>();
361            if candidates.is_empty() {
362                return Ok(());
363            }
364
365            candidates.sort_by(|(left_idx, left), (right_idx, right)| {
366                right.total_cmp(left).then_with(|| left_idx.cmp(right_idx))
367            });
368
369            let max_logit = candidates[0].1;
370            let sum = candidates
371                .iter()
372                .map(|(_, logit)| (*logit - max_logit).exp())
373                .sum::<f32>();
374            let mut cum_prob = 0.0;
375            let mut cutoff_idx = candidates.len();
376            for (i, (_, logit)) in candidates.iter().enumerate() {
377                cum_prob += (*logit - max_logit).exp() / sum;
378                if cum_prob >= self.p {
379                    cutoff_idx = i + 1;
380                    break;
381                }
382            }
383
384            for (idx, _) in candidates.into_iter().skip(cutoff_idx) {
385                ctx.logits[idx] = f32::NEG_INFINITY;
386            }
387        }
388        Ok(())
389    }
390
391    fn name(&self) -> &str {
392        "top_p"
393    }
394
395    fn priority(&self) -> ProcessorPriority {
396        ProcessorPriority::Low
397    }
398}
399
400/// Minimum-probability filtering processor.
401///
402/// A token remains eligible when its probability is at least `min_p` times
403/// the most likely token's probability. In logit space the equivalent
404/// threshold is `max_logit + ln(min_p)`, so this needs no softmax allocation.
405pub struct MinPProcessor {
406    pub min_p: f32,
407}
408
409impl MinPProcessor {
410    pub fn new(min_p: f32) -> Self {
411        Self { min_p }
412    }
413}
414
415impl LogitsProcessor for MinPProcessor {
416    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
417        if self.min_p > 0.0 && self.min_p <= 1.0 {
418            let max_logit = ctx.logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
419            let threshold = max_logit + self.min_p.ln();
420            for logit in ctx.logits.iter_mut() {
421                if *logit < threshold {
422                    *logit = f32::NEG_INFINITY;
423                }
424            }
425        }
426        Ok(())
427    }
428
429    fn name(&self) -> &str {
430        "min_p"
431    }
432
433    fn priority(&self) -> ProcessorPriority {
434        ProcessorPriority::Low
435    }
436}
437
438/// Repetition penalty processor
439pub struct RepetitionPenaltyProcessor {
440    pub penalty: f32,
441}
442
443impl RepetitionPenaltyProcessor {
444    pub fn new(penalty: f32) -> Self {
445        Self { penalty }
446    }
447}
448
449impl LogitsProcessor for RepetitionPenaltyProcessor {
450    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
451        if self.penalty != 1.0 {
452            for &token_id in ctx.token_frequencies.keys() {
453                if usize::from(token_id) >= ctx.logits.len() {
454                    continue;
455                }
456                let idx = usize::from(token_id);
457                let current_logit = ctx.logits[idx];
458                if current_logit > 0.0 {
459                    ctx.logits[idx] = current_logit / self.penalty;
460                } else {
461                    ctx.logits[idx] = current_logit * self.penalty;
462                }
463            }
464        }
465        Ok(())
466    }
467
468    fn name(&self) -> &str {
469        "repetition_penalty"
470    }
471
472    fn priority(&self) -> ProcessorPriority {
473        ProcessorPriority::High // Apply penalties early
474    }
475}
476
477/// OpenAI-compatible additive penalties over generated-token counts.
478///
479/// Repetition penalty is multiplicative and remains a separate processor.
480/// Presence and frequency penalties are additive and are applied afterwards:
481/// `logit -= presence * seen + frequency * count`.
482pub struct PresenceFrequencyPenaltyProcessor {
483    pub presence_penalty: f32,
484    pub frequency_penalty: f32,
485}
486
487impl PresenceFrequencyPenaltyProcessor {
488    pub fn new(presence_penalty: f32, frequency_penalty: f32) -> Self {
489        Self {
490            presence_penalty,
491            frequency_penalty,
492        }
493    }
494}
495
496impl LogitsProcessor for PresenceFrequencyPenaltyProcessor {
497    fn process(&self, ctx: &mut SamplingContext) -> Result<()> {
498        if self.presence_penalty == 0.0 && self.frequency_penalty == 0.0 {
499            return Ok(());
500        }
501        for (&token_id, &count) in ctx.token_frequencies {
502            let idx = usize::from(token_id);
503            if idx >= ctx.logits.len() || count == 0 {
504                continue;
505            }
506            ctx.logits[idx] -= self.presence_penalty + self.frequency_penalty * count as f32;
507        }
508        Ok(())
509    }
510
511    fn name(&self) -> &str {
512        "presence_frequency_penalty"
513    }
514
515    fn priority(&self) -> ProcessorPriority {
516        ProcessorPriority::High
517    }
518}
519
520/// Common samplers
521
522/// Greedy sampler (always picks highest probability token)
523pub struct GreedySampler;
524
525impl Sampler for GreedySampler {
526    fn sample(&self, logits: &[f32], _rng: &mut dyn RngCore) -> Result<TokenId> {
527        let max_idx = logits
528            .iter()
529            .enumerate()
530            .filter(|(_, logit)| logit.is_finite())
531            .reduce(|best, candidate| match candidate.1.total_cmp(best.1) {
532                std::cmp::Ordering::Greater => candidate,
533                std::cmp::Ordering::Equal if candidate.0 < best.0 => candidate,
534                _ => best,
535            })
536            .map(|(idx, _)| idx)
537            .ok_or_else(|| {
538                ferrum_types::FerrumError::backend("No finite logits available for sampling")
539            })?;
540
541        Ok(TokenId::new(max_idx as u32))
542    }
543
544    fn name(&self) -> &str {
545        "greedy"
546    }
547
548    fn is_deterministic(&self) -> bool {
549        true
550    }
551}
552
553#[cfg(test)]
554mod greedy_sampler_tests {
555    use super::{GreedySampler, Sampler, SamplingRng};
556
557    #[test]
558    fn ties_choose_the_lowest_token_id() {
559        let mut rng = SamplingRng::seeded(1);
560        let token = GreedySampler
561            .sample(&[-1.0, 4.0, 4.0, f32::NAN], &mut rng)
562            .unwrap();
563        assert_eq!(token.get(), 1);
564    }
565
566    #[test]
567    fn non_finite_logits_are_never_selected() {
568        let mut rng = SamplingRng::seeded(1);
569        let token = GreedySampler
570            .sample(&[f32::NAN, f32::INFINITY, -2.0], &mut rng)
571            .unwrap();
572        assert_eq!(token.get(), 2);
573        assert!(GreedySampler
574            .sample(&[f32::NAN, f32::INFINITY], &mut rng)
575            .is_err());
576    }
577}
578
579/// Multinomial sampler for probabilistic sampling
580pub struct MultinomialSampler;
581
582impl Sampler for MultinomialSampler {
583    fn sample(&self, logits: &[f32], rng: &mut dyn RngCore) -> Result<TokenId> {
584        // Convert logits to probabilities
585        let max_logit = logits.iter().fold(f32::NEG_INFINITY, |a, &b| a.max(b));
586
587        let mut probs: Vec<f32> = logits
588            .iter()
589            .map(|&logit| {
590                if logit.is_finite() && logit > f32::NEG_INFINITY {
591                    (logit - max_logit).exp()
592                } else {
593                    0.0
594                }
595            })
596            .collect();
597
598        let sum: f32 = probs.iter().sum();
599        if sum <= 0.0 {
600            return Err(ferrum_types::FerrumError::backend(
601                "No valid tokens for sampling",
602            ));
603        }
604
605        for prob in probs.iter_mut() {
606            *prob /= sum;
607        }
608
609        // Sample from categorical distribution
610        let threshold = rng.next_u32() as f32 / u32::MAX as f32;
611        let mut cumulative = 0.0;
612
613        for (idx, prob) in probs.iter().enumerate() {
614            cumulative += prob;
615            if cumulative >= threshold {
616                return Ok(TokenId::new(idx as u32));
617            }
618        }
619
620        // Fallback to last token (shouldn't happen with proper normalization)
621        Ok(TokenId::new((probs.len() - 1) as u32))
622    }
623
624    fn name(&self) -> &str {
625        "multinomial"
626    }
627
628    fn is_deterministic(&self) -> bool {
629        false
630    }
631}
632
633/// Sampling configuration builder
634pub struct SamplingConfigBuilder {
635    processors: Vec<Box<dyn LogitsProcessor>>,
636    sampler: Option<Box<dyn Sampler>>,
637}
638
639impl SamplingConfigBuilder {
640    /// Create new builder
641    pub fn new() -> Self {
642        Self {
643            processors: Vec::new(),
644            sampler: None,
645        }
646    }
647
648    /// Add temperature scaling
649    pub fn with_temperature(mut self, temperature: f32) -> Self {
650        if temperature > 0.0 && temperature != 1.0 {
651            self.processors
652                .push(Box::new(TemperatureProcessor::new(temperature)));
653        }
654        self
655    }
656
657    /// Add top-k filtering
658    pub fn with_top_k(mut self, k: usize) -> Self {
659        if k > 0 {
660            self.processors.push(Box::new(TopKProcessor::new(k)));
661        }
662        self
663    }
664
665    /// Add top-p filtering
666    pub fn with_top_p(mut self, p: f32) -> Self {
667        if p > 0.0 && p < 1.0 {
668            self.processors.push(Box::new(TopPProcessor::new(p)));
669        }
670        self
671    }
672
673    /// Add minimum-probability filtering.
674    pub fn with_min_p(mut self, min_p: f32) -> Self {
675        if min_p > 0.0 && min_p <= 1.0 {
676            self.processors.push(Box::new(MinPProcessor::new(min_p)));
677        }
678        self
679    }
680
681    /// Add repetition penalty
682    pub fn with_repetition_penalty(mut self, penalty: f32) -> Self {
683        if penalty != 1.0 {
684            self.processors
685                .push(Box::new(RepetitionPenaltyProcessor::new(penalty)));
686        }
687        self
688    }
689
690    /// Add OpenAI-compatible presence and frequency penalties.
691    pub fn with_presence_frequency_penalty(
692        mut self,
693        presence_penalty: f32,
694        frequency_penalty: f32,
695    ) -> Self {
696        if presence_penalty != 0.0 || frequency_penalty != 0.0 {
697            self.processors
698                .push(Box::new(PresenceFrequencyPenaltyProcessor::new(
699                    presence_penalty,
700                    frequency_penalty,
701                )));
702        }
703        self
704    }
705
706    /// Set sampler (greedy vs multinomial)
707    pub fn with_sampler(mut self, sampler: Box<dyn Sampler>) -> Self {
708        self.sampler = Some(sampler);
709        self
710    }
711
712    /// Build sampling configuration
713    pub fn build(self) -> SamplingConfig {
714        let mut chain = LogitsProcessorChain::new();
715        for processor in self.processors {
716            chain = chain.add_processor(processor);
717        }
718
719        let sampler = self.sampler.unwrap_or_else(|| Box::new(MultinomialSampler));
720
721        SamplingConfig {
722            processor_chain: chain,
723            sampler,
724        }
725    }
726}
727
728impl Default for SamplingConfigBuilder {
729    fn default() -> Self {
730        Self::new()
731    }
732}
733
734/// Complete sampling configuration
735pub struct SamplingConfig {
736    pub processor_chain: LogitsProcessorChain,
737    pub sampler: Box<dyn Sampler>,
738}
739
740impl fmt::Debug for SamplingConfig {
741    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
742        f.debug_struct("SamplingConfig")
743            .field("processor_chain", &self.processor_chain)
744            .field("sampler", &self.sampler.name())
745            .finish()
746    }
747}
748
749impl SamplingConfig {
750    /// Create from sampling parameters
751    pub fn from_params(params: &SamplingParams) -> Self {
752        let mut builder = SamplingConfigBuilder::new()
753            .with_temperature(params.temperature)
754            .with_repetition_penalty(params.repetition_penalty)
755            .with_presence_frequency_penalty(params.presence_penalty, params.frequency_penalty);
756
757        if let Some(min_p) = params.min_p {
758            builder = builder.with_min_p(min_p);
759        }
760
761        if let Some(top_k) = params.top_k {
762            builder = builder.with_top_k(top_k);
763        }
764
765        if params.top_p < 1.0 {
766            builder = builder.with_top_p(params.top_p);
767        }
768
769        // Choose sampler based on temperature
770        let sampler: Box<dyn Sampler> = if params.temperature == 0.0 {
771            Box::new(GreedySampler)
772        } else {
773            Box::new(MultinomialSampler)
774        };
775
776        builder.with_sampler(sampler).build()
777    }
778
779    /// Whether the current plan is exactly raw greedy argmax.
780    ///
781    /// The legacy speculative runner only represents temperature and drafts
782    /// with argmax. Any logits processor would otherwise be silently skipped.
783    pub fn supports_raw_greedy_speculation(&self) -> bool {
784        self.sampler.is_deterministic() && self.processor_chain.is_empty()
785    }
786
787    /// Process logits and sample token
788    pub fn sample(&self, mut ctx: SamplingContext, rng: &mut dyn RngCore) -> Result<TokenId> {
789        // Apply all logits processors
790        self.processor_chain.process(&mut ctx)?;
791
792        // Sample token
793        self.sampler.sample_with_context(&ctx, rng)
794    }
795}
796
797/// Sampling statistics for monitoring
798#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct SamplingStats {
800    /// Total sampling operations
801    pub total_samples: u64,
802    /// Average sampling time in microseconds
803    pub avg_sample_time_us: f64,
804    /// Distribution of sampled tokens
805    pub token_distribution: HashMap<TokenId, u64>,
806    /// Effective temperature (entropy-based measure)
807    pub effective_temperature: f32,
808    /// Processor execution times
809    pub processor_times: HashMap<String, f64>,
810}