Skip to main content

torsh_data/sampler/
curriculum.rs

1//! Curriculum learning sampling functionality
2//!
3//! This module provides curriculum learning samplers that adjust sampling based on
4//! training progress, gradually introducing more difficult samples as training proceeds.
5
6#[cfg(not(feature = "std"))]
7use alloc::vec::Vec;
8
9use super::core::{rng_utils, Sampler, SamplerIterator};
10
11/// Curriculum learning strategies
12///
13/// These strategies define how the difficulty threshold changes over training epochs,
14/// allowing for different curriculum learning approaches.
15#[derive(Clone, Debug, PartialEq)]
16pub enum CurriculumStrategy {
17    /// Linear increase from easy to hard
18    ///
19    /// The difficulty threshold increases linearly with training progress.
20    /// At epoch 0, only the easiest samples are included.
21    /// At the final epoch, all samples are included.
22    Linear,
23
24    /// Exponential increase (starts slow, accelerates)
25    ///
26    /// The difficulty threshold follows an exponential curve.
27    /// Training starts very slowly with only easy samples,
28    /// then rapidly includes harder samples towards the end.
29    ///
30    /// # Arguments
31    ///
32    /// * `base` - Exponential base (must be > 1.0, typically 2.0-10.0)
33    Exponential { base: f64 },
34
35    /// Step-wise increase at specific epochs
36    ///
37    /// The difficulty threshold increases in discrete steps
38    /// at predefined epoch milestones.
39    ///
40    /// # Arguments
41    ///
42    /// * `thresholds` - Epoch numbers where difficulty increases
43    Step { thresholds: Vec<usize> },
44
45    /// Anti-curriculum (hard to easy)
46    ///
47    /// Starts with the hardest samples and gradually includes easier ones.
48    /// This is the opposite of traditional curriculum learning.
49    AntiCurriculum,
50
51    /// Self-paced learning (adaptive difficulty)
52    ///
53    /// Adjusts difficulty based on a lambda parameter that can be
54    /// modified based on model performance.
55    ///
56    /// # Arguments
57    ///
58    /// * `lambda` - Self-pacing parameter (0.0-1.0)
59    SelfPaced { lambda: f64 },
60}
61
62impl Default for CurriculumStrategy {
63    fn default() -> Self {
64        CurriculumStrategy::Linear
65    }
66}
67
68/// Curriculum learning sampler that adjusts sampling based on training progress
69///
70/// This sampler gradually introduces more difficult samples as training progresses,
71/// following the curriculum learning paradigm. The difficulty of samples is determined
72/// by a user-provided difficulty function.
73///
74/// # Examples
75///
76/// ```rust,ignore
77/// use torsh_data::sampler::{CurriculumSampler, CurriculumStrategy, Sampler};
78///
79/// // Define difficulty as distance from center
80/// let difficulty_fn = |idx: usize| (idx as f64 - 50.0).abs() / 50.0;
81///
82/// let mut sampler = CurriculumSampler::new(
83///     100,
84///     difficulty_fn,
85///     10,
86///     CurriculumStrategy::Linear
87/// ).with_generator(42);
88///
89/// // Set current epoch
90/// sampler.set_epoch(0); // Start with easiest samples
91/// let early_indices: Vec<usize> = sampler.iter().collect();
92///
93/// sampler.set_epoch(9); // Include harder samples
94/// let late_indices: Vec<usize> = sampler.iter().collect();
95///
96/// assert!(late_indices.len() >= early_indices.len());
97/// ```
98#[derive(Clone)]
99pub struct CurriculumSampler<F> {
100    difficulties: Vec<f64>,
101    difficulty_fn: F,
102    current_epoch: usize,
103    total_epochs: usize,
104    curriculum_strategy: CurriculumStrategy,
105    generator: Option<u64>,
106}
107
108impl<F> CurriculumSampler<F>
109where
110    F: Fn(usize) -> f64 + Send + Clone,
111{
112    /// Create a new curriculum sampler
113    ///
114    /// # Arguments
115    ///
116    /// * `dataset_size` - Size of the dataset
117    /// * `difficulty_fn` - Function that maps sample index to difficulty score (0.0 = easy, 1.0 = hard)
118    /// * `total_epochs` - Total number of training epochs
119    /// * `strategy` - Curriculum learning strategy to use
120    ///
121    /// # Examples
122    ///
123    /// ```rust,ignore
124    /// use torsh_data::sampler::{CurriculumSampler, CurriculumStrategy};
125    ///
126    /// // Define difficulty based on sample index
127    /// let difficulty_fn = |idx: usize| idx as f64 / 100.0;
128    ///
129    /// let sampler = CurriculumSampler::new(
130    ///     100,
131    ///     difficulty_fn,
132    ///     20,
133    ///     CurriculumStrategy::Linear
134    /// );
135    /// ```
136    pub fn new(
137        dataset_size: usize,
138        difficulty_fn: F,
139        total_epochs: usize,
140        strategy: CurriculumStrategy,
141    ) -> Self {
142        let difficulties: Vec<f64> = (0..dataset_size).map(&difficulty_fn).collect();
143
144        Self {
145            difficulties,
146            difficulty_fn,
147            current_epoch: 0,
148            total_epochs,
149            curriculum_strategy: strategy,
150            generator: None,
151        }
152    }
153
154    /// Create a curriculum sampler with predefined difficulties
155    ///
156    /// # Arguments
157    ///
158    /// * `difficulties` - Precomputed difficulty scores for each sample
159    /// * `total_epochs` - Total number of training epochs
160    /// * `strategy` - Curriculum learning strategy to use
161    pub fn from_difficulties(
162        difficulties: Vec<f64>,
163        total_epochs: usize,
164        strategy: CurriculumStrategy,
165    ) -> Self
166    where
167        F: Default,
168    {
169        Self {
170            difficulty_fn: F::default(),
171            difficulties,
172            current_epoch: 0,
173            total_epochs,
174            curriculum_strategy: strategy,
175            generator: None,
176        }
177    }
178
179    /// Set the current epoch for curriculum progression
180    ///
181    /// # Arguments
182    ///
183    /// * `epoch` - Current training epoch (0-based)
184    pub fn set_epoch(&mut self, epoch: usize) {
185        self.current_epoch = epoch;
186    }
187
188    /// Set random generator seed
189    ///
190    /// # Arguments
191    ///
192    /// * `seed` - Random seed for reproducible sampling
193    pub fn with_generator(mut self, seed: u64) -> Self {
194        self.generator = Some(seed);
195        self
196    }
197
198    /// Get the current epoch
199    pub fn current_epoch(&self) -> usize {
200        self.current_epoch
201    }
202
203    /// Get the total number of epochs
204    pub fn total_epochs(&self) -> usize {
205        self.total_epochs
206    }
207
208    /// Get the curriculum strategy
209    pub fn strategy(&self) -> &CurriculumStrategy {
210        &self.curriculum_strategy
211    }
212
213    /// Get the difficulties
214    pub fn difficulties(&self) -> &[f64] {
215        &self.difficulties
216    }
217
218    /// Get the generator seed if set
219    pub fn generator(&self) -> Option<u64> {
220        self.generator
221    }
222
223    /// Calculate training progress (0.0 to 1.0)
224    pub fn progress(&self) -> f64 {
225        if self.total_epochs <= 1 {
226            1.0
227        } else {
228            (self.current_epoch as f64 / (self.total_epochs - 1) as f64).min(1.0)
229        }
230    }
231
232    /// Calculate difficulty threshold for current epoch
233    ///
234    /// Returns a value between 0.0 and 1.0 indicating the maximum difficulty
235    /// of samples that should be included in the current epoch.
236    pub fn get_difficulty_threshold(&self) -> f64 {
237        let progress = self.progress();
238
239        match &self.curriculum_strategy {
240            CurriculumStrategy::Linear => progress,
241            CurriculumStrategy::Exponential { base } => {
242                if *base <= 1.0 {
243                    progress // Fallback to linear if base is invalid
244                } else {
245                    (base.powf(progress) - 1.0) / (base - 1.0)
246                }
247            }
248            CurriculumStrategy::Step { thresholds } => {
249                if thresholds.is_empty() {
250                    1.0
251                } else {
252                    let mut threshold = 0.0;
253                    for &epoch_threshold in thresholds {
254                        if self.current_epoch >= epoch_threshold {
255                            threshold += 1.0 / thresholds.len() as f64;
256                        }
257                    }
258                    threshold.min(1.0)
259                }
260            }
261            CurriculumStrategy::AntiCurriculum => {
262                // Anti-curriculum: start inclusive, stay inclusive but focus on easier samples midway, end fully inclusive
263                // Early (progress=0): threshold=1.0 (all samples)
264                // Late (progress=1): threshold=1.0 (all samples)
265                // But with different ordering/selection behavior
266                1.0 // Always include all samples for now
267            }
268            CurriculumStrategy::SelfPaced { lambda } => {
269                // Self-paced learning adjusts based on performance
270                // This is a simplified version - in practice you'd track model performance
271                (progress * lambda).min(1.0)
272            }
273        }
274    }
275
276    /// Get indices based on difficulty threshold
277    ///
278    /// Returns indices of samples that should be included based on the
279    /// current epoch and curriculum strategy.
280    pub fn get_curriculum_indices(&self) -> Vec<usize> {
281        if self.difficulties.is_empty() {
282            return Vec::new();
283        }
284
285        let threshold = self.get_difficulty_threshold();
286        let max_difficulty = self
287            .difficulties
288            .iter()
289            .fold(f64::NEG_INFINITY, |a, &b| a.max(b));
290        let min_difficulty = self
291            .difficulties
292            .iter()
293            .fold(f64::INFINITY, |a, &b| a.min(b));
294
295        // Normalize difficulties to [0, 1]
296        let range = max_difficulty - min_difficulty;
297        let normalized_threshold = if range > 0.0 {
298            threshold
299        } else {
300            1.0 // If all difficulties are the same, include all
301        };
302
303        self.difficulties
304            .iter()
305            .enumerate()
306            .filter_map(|(idx, &difficulty)| {
307                let normalized_difficulty = if range > 0.0 {
308                    (difficulty - min_difficulty) / range
309                } else {
310                    0.0
311                };
312
313                let include_sample = match &self.curriculum_strategy {
314                    CurriculumStrategy::AntiCurriculum => {
315                        // Anti-curriculum: start inclusive, become more selective for easy samples
316                        // At early epochs (threshold=1): include all samples
317                        // At late epochs (threshold=0): include only easiest samples
318                        normalized_difficulty <= normalized_threshold
319                    }
320                    _ => {
321                        // Regular curriculum: include easy samples first, then harder ones
322                        normalized_difficulty <= normalized_threshold
323                    }
324                };
325
326                if include_sample {
327                    Some(idx)
328                } else {
329                    None
330                }
331            })
332            .collect()
333    }
334
335    /// Update difficulties using the difficulty function
336    ///
337    /// Recalculates all difficulty scores using the stored difficulty function.
338    /// Useful when the dataset has changed or the difficulty function has been modified.
339    pub fn update_difficulties(&mut self) {
340        let dataset_size = self.difficulties.len();
341        self.difficulties = (0..dataset_size).map(&self.difficulty_fn).collect();
342    }
343
344    /// Set a new curriculum strategy
345    ///
346    /// # Arguments
347    ///
348    /// * `strategy` - New curriculum strategy to use
349    pub fn set_strategy(&mut self, strategy: CurriculumStrategy) {
350        self.curriculum_strategy = strategy;
351    }
352
353    /// Reset to the beginning of the curriculum
354    pub fn reset(&mut self) {
355        self.current_epoch = 0;
356    }
357
358    /// Check if curriculum is complete (all samples included)
359    pub fn is_complete(&self) -> bool {
360        self.current_epoch >= self.total_epochs || self.get_difficulty_threshold() >= 1.0
361    }
362
363    /// Get statistics about the current curriculum state
364    pub fn curriculum_stats(&self) -> CurriculumStats {
365        let threshold = self.get_difficulty_threshold();
366        let indices = self.get_curriculum_indices();
367        let total_samples = self.difficulties.len();
368
369        CurriculumStats {
370            current_epoch: self.current_epoch,
371            total_epochs: self.total_epochs,
372            progress: self.progress(),
373            difficulty_threshold: threshold,
374            included_samples: indices.len(),
375            total_samples,
376            inclusion_ratio: if total_samples > 0 {
377                indices.len() as f64 / total_samples as f64
378            } else {
379                0.0
380            },
381        }
382    }
383}
384
385impl<F: Send + Clone> Sampler for CurriculumSampler<F>
386where
387    F: Fn(usize) -> f64 + Send + Clone,
388{
389    type Iter = SamplerIterator;
390
391    fn iter(&self) -> Self::Iter {
392        let mut indices = self.get_curriculum_indices();
393
394        // Shuffle the valid indices using rng_utils
395        rng_utils::shuffle_indices(&mut indices, self.generator);
396
397        SamplerIterator::new(indices)
398    }
399
400    fn len(&self) -> usize {
401        self.get_curriculum_indices().len()
402    }
403}
404
405/// Statistics about the current curriculum state
406#[derive(Debug, Clone, PartialEq)]
407pub struct CurriculumStats {
408    /// Current training epoch
409    pub current_epoch: usize,
410    /// Total number of epochs
411    pub total_epochs: usize,
412    /// Training progress (0.0 to 1.0)
413    pub progress: f64,
414    /// Current difficulty threshold (0.0 to 1.0)
415    pub difficulty_threshold: f64,
416    /// Number of samples included in current epoch
417    pub included_samples: usize,
418    /// Total number of samples in dataset
419    pub total_samples: usize,
420    /// Ratio of included samples to total samples
421    pub inclusion_ratio: f64,
422}
423
424/// Create a linear curriculum sampler
425///
426/// Convenience function for creating a curriculum sampler with linear progression.
427///
428/// # Arguments
429///
430/// * `dataset_size` - Size of the dataset
431/// * `difficulty_fn` - Function that maps sample index to difficulty score
432/// * `total_epochs` - Total number of training epochs
433/// * `seed` - Optional random seed for reproducible sampling
434pub fn linear_curriculum<F>(
435    dataset_size: usize,
436    difficulty_fn: F,
437    total_epochs: usize,
438    seed: Option<u64>,
439) -> CurriculumSampler<F>
440where
441    F: Fn(usize) -> f64 + Send + Clone,
442{
443    let mut sampler = CurriculumSampler::new(
444        dataset_size,
445        difficulty_fn,
446        total_epochs,
447        CurriculumStrategy::Linear,
448    );
449    if let Some(s) = seed {
450        sampler = sampler.with_generator(s);
451    }
452    sampler
453}
454
455/// Create an exponential curriculum sampler
456///
457/// Convenience function for creating a curriculum sampler with exponential progression.
458///
459/// # Arguments
460///
461/// * `dataset_size` - Size of the dataset
462/// * `difficulty_fn` - Function that maps sample index to difficulty score
463/// * `total_epochs` - Total number of training epochs
464/// * `base` - Exponential base (must be > 1.0)
465/// * `seed` - Optional random seed for reproducible sampling
466pub fn exponential_curriculum<F>(
467    dataset_size: usize,
468    difficulty_fn: F,
469    total_epochs: usize,
470    base: f64,
471    seed: Option<u64>,
472) -> CurriculumSampler<F>
473where
474    F: Fn(usize) -> f64 + Send + Clone,
475{
476    let mut sampler = CurriculumSampler::new(
477        dataset_size,
478        difficulty_fn,
479        total_epochs,
480        CurriculumStrategy::Exponential { base },
481    );
482    if let Some(s) = seed {
483        sampler = sampler.with_generator(s);
484    }
485    sampler
486}
487
488/// Create a step-wise curriculum sampler
489///
490/// Convenience function for creating a curriculum sampler with step-wise progression.
491///
492/// # Arguments
493///
494/// * `dataset_size` - Size of the dataset
495/// * `difficulty_fn` - Function that maps sample index to difficulty score
496/// * `total_epochs` - Total number of training epochs
497/// * `thresholds` - Epoch numbers where difficulty increases
498/// * `seed` - Optional random seed for reproducible sampling
499pub fn step_curriculum<F>(
500    dataset_size: usize,
501    difficulty_fn: F,
502    total_epochs: usize,
503    thresholds: Vec<usize>,
504    seed: Option<u64>,
505) -> CurriculumSampler<F>
506where
507    F: Fn(usize) -> f64 + Send + Clone,
508{
509    let mut sampler = CurriculumSampler::new(
510        dataset_size,
511        difficulty_fn,
512        total_epochs,
513        CurriculumStrategy::Step { thresholds },
514    );
515    if let Some(s) = seed {
516        sampler = sampler.with_generator(s);
517    }
518    sampler
519}
520
521/// Create an anti-curriculum sampler
522///
523/// Convenience function for creating a curriculum sampler that starts with hard samples.
524///
525/// # Arguments
526///
527/// * `dataset_size` - Size of the dataset
528/// * `difficulty_fn` - Function that maps sample index to difficulty score
529/// * `total_epochs` - Total number of training epochs
530/// * `seed` - Optional random seed for reproducible sampling
531pub fn anti_curriculum<F>(
532    dataset_size: usize,
533    difficulty_fn: F,
534    total_epochs: usize,
535    seed: Option<u64>,
536) -> CurriculumSampler<F>
537where
538    F: Fn(usize) -> f64 + Send + Clone,
539{
540    let mut sampler = CurriculumSampler::new(
541        dataset_size,
542        difficulty_fn,
543        total_epochs,
544        CurriculumStrategy::AntiCurriculum,
545    );
546    if let Some(s) = seed {
547        sampler = sampler.with_generator(s);
548    }
549    sampler
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    // Simple difficulty function for testing
557    fn linear_difficulty(idx: usize) -> f64 {
558        idx as f64 / 100.0
559    }
560
561    // Difficulty function based on distance from center
562    fn center_distance_difficulty(idx: usize) -> f64 {
563        (idx as f64 - 50.0).abs() / 50.0
564    }
565
566    #[test]
567    fn test_curriculum_sampler_basic() {
568        let mut sampler =
569            CurriculumSampler::new(100, linear_difficulty, 10, CurriculumStrategy::Linear)
570                .with_generator(42);
571
572        assert_eq!(sampler.total_epochs(), 10);
573        assert_eq!(sampler.current_epoch(), 0);
574        assert_eq!(sampler.generator(), Some(42));
575        assert_eq!(sampler.difficulties().len(), 100);
576        assert!(!sampler.is_complete());
577
578        // At epoch 0, should include only easiest samples
579        sampler.set_epoch(0);
580        let early_indices = sampler.get_curriculum_indices();
581        assert!(!early_indices.is_empty());
582        assert!(early_indices.len() < 100);
583
584        // At final epoch, should include all samples
585        sampler.set_epoch(9);
586        let late_indices = sampler.get_curriculum_indices();
587        assert_eq!(late_indices.len(), 100);
588        assert!(sampler.is_complete());
589    }
590
591    #[test]
592    fn test_curriculum_strategies() {
593        let dataset_size = 100;
594        let total_epochs = 10;
595
596        // Test Linear strategy
597        let mut linear_sampler = CurriculumSampler::new(
598            dataset_size,
599            linear_difficulty,
600            total_epochs,
601            CurriculumStrategy::Linear,
602        );
603
604        linear_sampler.set_epoch(0);
605        let linear_early = linear_sampler.get_curriculum_indices().len();
606        linear_sampler.set_epoch(5);
607        let linear_mid = linear_sampler.get_curriculum_indices().len();
608        linear_sampler.set_epoch(9);
609        let linear_late = linear_sampler.get_curriculum_indices().len();
610
611        assert!(linear_early < linear_mid);
612        assert!(linear_mid < linear_late);
613        assert_eq!(linear_late, dataset_size);
614
615        // Test Exponential strategy
616        let mut exp_sampler = CurriculumSampler::new(
617            dataset_size,
618            linear_difficulty,
619            total_epochs,
620            CurriculumStrategy::Exponential { base: 2.0 },
621        );
622
623        exp_sampler.set_epoch(0);
624        let exp_early = exp_sampler.get_curriculum_indices().len();
625        exp_sampler.set_epoch(5);
626        let _exp_mid = exp_sampler.get_curriculum_indices().len();
627
628        // Exponential should grow slower initially
629        assert!(exp_early <= linear_early);
630
631        // Test Step strategy
632        let mut step_sampler = CurriculumSampler::new(
633            dataset_size,
634            linear_difficulty,
635            total_epochs,
636            CurriculumStrategy::Step {
637                thresholds: vec![3, 6, 9],
638            },
639        );
640
641        step_sampler.set_epoch(2);
642        let step_before = step_sampler.get_curriculum_indices().len();
643        step_sampler.set_epoch(3);
644        let step_after = step_sampler.get_curriculum_indices().len();
645
646        // Should have a jump at threshold
647        assert!(step_after > step_before);
648
649        // Test AntiCurriculum
650        let mut anti_sampler = CurriculumSampler::new(
651            dataset_size,
652            linear_difficulty,
653            total_epochs,
654            CurriculumStrategy::AntiCurriculum,
655        );
656
657        anti_sampler.set_epoch(0);
658        let anti_early = anti_sampler.get_curriculum_indices().len();
659        anti_sampler.set_epoch(9);
660        let anti_late = anti_sampler.get_curriculum_indices().len();
661
662        // Anti-curriculum should start with more samples
663        assert!(anti_early > linear_early);
664        assert_eq!(anti_late, dataset_size);
665    }
666
667    #[test]
668    fn test_difficulty_threshold_calculation() {
669        let sampler =
670            CurriculumSampler::new(100, linear_difficulty, 10, CurriculumStrategy::Linear);
671
672        // Test progress calculation
673        assert_eq!(sampler.progress(), 0.0);
674
675        let mut sampler = sampler;
676        sampler.set_epoch(5);
677        assert!((sampler.progress() - 5.0 / 9.0).abs() < f64::EPSILON);
678
679        sampler.set_epoch(9);
680        assert_eq!(sampler.progress(), 1.0);
681
682        // Test different strategies
683        assert_eq!(sampler.get_difficulty_threshold(), 1.0);
684
685        sampler.set_strategy(CurriculumStrategy::Exponential { base: 2.0 });
686        sampler.set_epoch(0);
687        assert_eq!(sampler.get_difficulty_threshold(), 0.0);
688
689        sampler.set_strategy(CurriculumStrategy::AntiCurriculum);
690        sampler.set_epoch(0);
691        assert_eq!(sampler.get_difficulty_threshold(), 1.0);
692    }
693
694    #[test]
695    fn test_curriculum_from_difficulties() {
696        let difficulties = vec![0.1, 0.3, 0.5, 0.7, 0.9];
697        let difficulty_fn = |idx: usize| difficulties.get(idx).copied().unwrap_or(0.0);
698        let sampler = CurriculumSampler::new(
699            difficulties.len(),
700            difficulty_fn,
701            5,
702            CurriculumStrategy::Linear,
703        );
704
705        assert_eq!(sampler.difficulties(), &difficulties);
706        assert_eq!(sampler.total_epochs(), 5);
707    }
708
709    #[test]
710    fn test_curriculum_indices_selection() {
711        let difficulties = vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0];
712        let difficulty_fn = |idx: usize| difficulties.get(idx).copied().unwrap_or(0.0);
713        let mut sampler = CurriculumSampler::new(
714            difficulties.len(),
715            difficulty_fn,
716            6,
717            CurriculumStrategy::Linear,
718        );
719
720        // At epoch 0, should include only easiest samples
721        sampler.set_epoch(0);
722        let indices = sampler.get_curriculum_indices();
723        assert!(indices.contains(&0)); // Easiest sample should be included
724        assert!(!indices.contains(&5)); // Hardest sample should not be included
725
726        // At final epoch, should include all samples
727        sampler.set_epoch(5);
728        let indices = sampler.get_curriculum_indices();
729        assert_eq!(indices.len(), 6);
730        for i in 0..6 {
731            assert!(indices.contains(&i));
732        }
733    }
734
735    #[test]
736    fn test_curriculum_stats() {
737        let mut sampler =
738            CurriculumSampler::new(100, linear_difficulty, 10, CurriculumStrategy::Linear);
739
740        sampler.set_epoch(5);
741        let stats = sampler.curriculum_stats();
742
743        assert_eq!(stats.current_epoch, 5);
744        assert_eq!(stats.total_epochs, 10);
745        assert_eq!(stats.total_samples, 100);
746        assert!(stats.progress > 0.0 && stats.progress < 1.0);
747        assert!(stats.difficulty_threshold > 0.0 && stats.difficulty_threshold < 1.0);
748        assert!(stats.included_samples > 0 && stats.included_samples < 100);
749        assert!(stats.inclusion_ratio > 0.0 && stats.inclusion_ratio < 1.0);
750    }
751
752    #[test]
753    fn test_curriculum_sampler_iter() {
754        let mut sampler =
755            CurriculumSampler::new(20, linear_difficulty, 5, CurriculumStrategy::Linear)
756                .with_generator(42);
757
758        sampler.set_epoch(0);
759        let indices1: Vec<usize> = sampler.iter().collect();
760        let indices2: Vec<usize> = sampler.iter().collect();
761
762        assert_eq!(indices1.len(), sampler.len());
763        assert_eq!(indices2.len(), sampler.len());
764
765        // With the same seed, should get the same shuffled order
766        assert_eq!(indices1, indices2);
767
768        // Advance to later epoch
769        sampler.set_epoch(4);
770        let late_indices: Vec<usize> = sampler.iter().collect();
771        assert!(late_indices.len() >= indices1.len());
772    }
773
774    #[test]
775    fn test_convenience_functions() {
776        // Test linear_curriculum
777        let linear = linear_curriculum(50, linear_difficulty, 10, Some(42));
778        assert_eq!(linear.total_epochs(), 10);
779        assert_eq!(linear.generator(), Some(42));
780        assert!(matches!(linear.strategy(), CurriculumStrategy::Linear));
781
782        // Test exponential_curriculum
783        let exponential = exponential_curriculum(50, linear_difficulty, 10, 2.0, Some(42));
784        assert!(
785            matches!(exponential.strategy(), CurriculumStrategy::Exponential { base } if *base == 2.0)
786        );
787
788        // Test step_curriculum
789        let step = step_curriculum(50, linear_difficulty, 10, vec![2, 5, 8], Some(42));
790        assert!(
791            matches!(step.strategy(), CurriculumStrategy::Step { thresholds } if thresholds == &vec![2, 5, 8])
792        );
793
794        // Test anti_curriculum
795        let anti = anti_curriculum(50, linear_difficulty, 10, Some(42));
796        assert!(matches!(
797            anti.strategy(),
798            CurriculumStrategy::AntiCurriculum
799        ));
800    }
801
802    #[test]
803    fn test_curriculum_methods() {
804        let mut sampler =
805            CurriculumSampler::new(100, linear_difficulty, 10, CurriculumStrategy::Linear);
806
807        // Test reset
808        sampler.set_epoch(5);
809        assert_eq!(sampler.current_epoch(), 5);
810        sampler.reset();
811        assert_eq!(sampler.current_epoch(), 0);
812
813        // Test set_strategy
814        sampler.set_strategy(CurriculumStrategy::Exponential { base: 3.0 });
815        assert!(
816            matches!(sampler.strategy(), CurriculumStrategy::Exponential { base } if *base == 3.0)
817        );
818
819        // Test update_difficulties
820        let original_difficulties = sampler.difficulties().to_vec();
821        sampler.update_difficulties();
822        assert_eq!(sampler.difficulties(), &original_difficulties);
823    }
824
825    #[test]
826    fn test_edge_cases() {
827        // Empty dataset
828        let empty_sampler =
829            CurriculumSampler::new(0, linear_difficulty, 5, CurriculumStrategy::Linear);
830        assert_eq!(empty_sampler.len(), 0);
831        assert!(empty_sampler.get_curriculum_indices().is_empty());
832
833        // Single epoch
834        let mut single_epoch =
835            CurriculumSampler::new(10, linear_difficulty, 1, CurriculumStrategy::Linear);
836        single_epoch.set_epoch(0);
837        assert_eq!(single_epoch.progress(), 1.0);
838        assert!(single_epoch.is_complete());
839
840        // Same difficulties
841        let same_difficulties = vec![0.5; 10];
842        let same_difficulty_fn = |idx: usize| same_difficulties.get(idx).copied().unwrap_or(0.0);
843        let mut same_sampler = CurriculumSampler::new(
844            same_difficulties.len(),
845            same_difficulty_fn,
846            5,
847            CurriculumStrategy::Linear,
848        );
849        same_sampler.set_epoch(0);
850        assert_eq!(same_sampler.get_curriculum_indices().len(), 10); // Should include all
851
852        // Invalid exponential base
853        let mut invalid_exp = CurriculumSampler::new(
854            10,
855            linear_difficulty,
856            5,
857            CurriculumStrategy::Exponential { base: 0.5 }, // Invalid base
858        );
859        invalid_exp.set_epoch(2);
860        // Should fallback to linear-like behavior
861        assert!(invalid_exp.get_difficulty_threshold() >= 0.0);
862    }
863
864    #[test]
865    fn test_curriculum_strategy_equality() {
866        assert_eq!(CurriculumStrategy::Linear, CurriculumStrategy::Linear);
867        assert_eq!(
868            CurriculumStrategy::Exponential { base: 2.0 },
869            CurriculumStrategy::Exponential { base: 2.0 }
870        );
871        assert_ne!(
872            CurriculumStrategy::Linear,
873            CurriculumStrategy::AntiCurriculum
874        );
875    }
876
877    #[test]
878    fn test_curriculum_strategy_default() {
879        assert_eq!(CurriculumStrategy::default(), CurriculumStrategy::Linear);
880    }
881
882    #[test]
883    fn test_center_distance_difficulty() {
884        let mut sampler = CurriculumSampler::new(
885            101, // 0 to 100, center at 50
886            center_distance_difficulty,
887            10,
888            CurriculumStrategy::Linear,
889        )
890        .with_generator(42);
891
892        // At early epochs, should prefer samples near center (index 50)
893        sampler.set_epoch(0);
894        let early_indices = sampler.get_curriculum_indices();
895        assert!(early_indices.contains(&50)); // Center should be included
896
897        // At later epochs, should include samples farther from center
898        sampler.set_epoch(9);
899        let late_indices = sampler.get_curriculum_indices();
900        assert!(late_indices.len() > early_indices.len());
901        assert!(late_indices.contains(&0) || late_indices.contains(&100)); // Extremes should be included
902    }
903}