use crate::scenario_gen::{level_seed, ScenarioSpec};
const DEFAULT_PRIOR: f64 = 0.5;
#[derive(Clone, Debug)]
pub struct AdaptiveCurriculum {
levels: Vec<u64>,
solves: Vec<u32>,
attempts: Vec<u32>,
prior: f64,
}
impl AdaptiveCurriculum {
pub fn new(levels: impl IntoIterator<Item = u64>) -> Self {
Self::with_prior(levels, DEFAULT_PRIOR)
}
pub fn with_prior(levels: impl IntoIterator<Item = u64>, prior: f64) -> Self {
let mut deduped: Vec<u64> = Vec::new();
for lv in levels {
if !deduped.contains(&lv) {
deduped.push(lv);
}
}
assert!(
!deduped.is_empty(),
"curriculum needs a non-empty level set"
);
let n = deduped.len();
Self {
levels: deduped,
solves: vec![0; n],
attempts: vec![0; n],
prior,
}
}
pub fn from_spec(spec: &ScenarioSpec, n_levels: u64) -> Self {
assert!(n_levels > 0, "curriculum needs at least one level");
Self::new((0..n_levels).map(|i| level_seed(spec, i)))
}
pub fn levels(&self) -> &[u64] {
&self.levels
}
fn index_of(&self, level: u64) -> Option<usize> {
self.levels.iter().position(|&l| l == level)
}
pub fn success_rate(&self, level: u64) -> Option<f64> {
let i = self.index_of(level)?;
let a = self.attempts[i];
Some(if a == 0 {
self.prior
} else {
self.solves[i] as f64 / a as f64
})
}
pub fn weight(&self, level: u64) -> f64 {
match self.success_rate(level) {
Some(p) => p * (1.0 - p),
None => 0.0,
}
}
pub fn record(&mut self, level: u64, solved: bool) {
match self.index_of(level) {
Some(i) => {
self.attempts[i] += 1;
self.solves[i] += u32::from(solved);
}
None => debug_assert!(false, "recorded outcome for off-schedule level {level}"),
}
}
pub fn select_next(&self) -> u64 {
let mut best = self.levels[0];
let mut best_w = self.weight(best);
for &lv in &self.levels[1..] {
let w = self.weight(lv);
if w > best_w {
best = lv;
best_w = w;
}
}
best
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zpd_weighting_favors_mid_difficulty() {
let mut c = AdaptiveCurriculum::new([10u64, 20, 30]);
for _ in 0..4 {
c.record(10, true); c.record(30, false); }
c.record(20, true);
c.record(20, false);
c.record(20, true);
c.record(20, false);
assert!((c.weight(10) - 0.0).abs() < 1e-12);
assert!((c.weight(30) - 0.0).abs() < 1e-12);
assert!(c.weight(20) > c.weight(10));
assert!(c.weight(20) > c.weight(30));
assert_eq!(c.select_next(), 20);
}
#[test]
fn deterministic_given_success_history() {
let history = [(10u64, true), (30, false), (20, true), (20, false)];
let build = || {
let mut c = AdaptiveCurriculum::new([10u64, 20, 30]);
for &(lv, ok) in &history {
c.record(lv, ok);
}
c
};
let a = build();
let b = build();
assert_eq!(a.select_next(), b.select_next());
for lv in [10u64, 20, 30] {
assert_eq!(a.weight(lv), b.weight(lv));
}
}
#[test]
fn explores_unseen_levels_before_replaying() {
let mut c = AdaptiveCurriculum::new([5u64, 6, 7]);
assert_eq!(c.select_next(), 5);
c.record(5, true); assert_eq!(c.select_next(), 6);
c.record(6, true);
assert_eq!(c.select_next(), 7);
}
#[test]
fn success_rate_reports_prior_for_unseen_and_none_off_schedule() {
let mut c = AdaptiveCurriculum::with_prior([1u64, 2], 0.5);
assert_eq!(c.success_rate(1), Some(0.5));
assert_eq!(c.success_rate(99), None);
c.record(1, true);
c.record(1, false);
assert_eq!(c.success_rate(1), Some(0.5));
}
#[test]
fn from_spec_materializes_level_seeds() {
let spec = ScenarioSpec {
start_level: 100,
num_levels: 4,
..ScenarioSpec::default()
};
let c = AdaptiveCurriculum::from_spec(&spec, 4);
assert_eq!(c.levels(), &[100, 101, 102, 103]);
}
#[test]
fn deduplicates_candidate_levels() {
let c = AdaptiveCurriculum::new([7u64, 7, 8, 7, 8]);
assert_eq!(c.levels(), &[7, 8]);
}
}