mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-11 08:42:36 +02:00
Initial commit: Vestige v1.0.0 - Cognitive memory MCP server
FSRS-6 spaced repetition, spreading activation, synaptic tagging, hippocampal indexing, and 130 years of memory research. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
f9c60eb5a7
169 changed files with 97206 additions and 0 deletions
477
crates/vestige-core/src/fsrs/algorithm.rs
Normal file
477
crates/vestige-core/src/fsrs/algorithm.rs
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
//! FSRS-6 Core Algorithm Implementation
|
||||
//!
|
||||
//! Implements the mathematical formulas for the FSRS-6 algorithm.
|
||||
//! All functions are pure and deterministic for testability.
|
||||
|
||||
use super::scheduler::Rating;
|
||||
|
||||
// ============================================================================
|
||||
// FSRS-6 CONSTANTS (21 Parameters)
|
||||
// ============================================================================
|
||||
|
||||
/// FSRS-6 default weights (w0 to w20)
|
||||
/// Trained on millions of Anki reviews - 20-30% more efficient than SM-2
|
||||
pub const FSRS6_WEIGHTS: [f64; 21] = [
|
||||
0.212, // w0: Initial stability for Again
|
||||
1.2931, // w1: Initial stability for Hard
|
||||
2.3065, // w2: Initial stability for Good
|
||||
8.2956, // w3: Initial stability for Easy
|
||||
6.4133, // w4: Initial difficulty base
|
||||
0.8334, // w5: Initial difficulty grade modifier
|
||||
3.0194, // w6: Difficulty delta
|
||||
0.001, // w7: Difficulty mean reversion
|
||||
1.8722, // w8: Stability increase base
|
||||
0.1666, // w9: Stability saturation
|
||||
0.796, // w10: Retrievability influence on stability
|
||||
1.4835, // w11: Forget stability base
|
||||
0.0614, // w12: Forget difficulty influence
|
||||
0.2629, // w13: Forget stability influence
|
||||
1.6483, // w14: Forget retrievability influence
|
||||
0.6014, // w15: Hard penalty
|
||||
1.8729, // w16: Easy bonus
|
||||
0.5425, // w17: Same-day review base (NEW in FSRS-6)
|
||||
0.0912, // w18: Same-day review grade modifier (NEW in FSRS-6)
|
||||
0.0658, // w19: Same-day review stability influence (NEW in FSRS-6)
|
||||
0.1542, // w20: Forgetting curve decay (NEW in FSRS-6 - PERSONALIZABLE)
|
||||
];
|
||||
|
||||
/// Maximum difficulty value
|
||||
pub const MAX_DIFFICULTY: f64 = 10.0;
|
||||
|
||||
/// Minimum difficulty value
|
||||
pub const MIN_DIFFICULTY: f64 = 1.0;
|
||||
|
||||
/// Minimum stability value (days)
|
||||
pub const MIN_STABILITY: f64 = 0.1;
|
||||
|
||||
/// Maximum stability value (days) - 100 years
|
||||
pub const MAX_STABILITY: f64 = 36500.0;
|
||||
|
||||
/// Default desired retention rate (90%)
|
||||
pub const DEFAULT_RETENTION: f64 = 0.9;
|
||||
|
||||
/// Default forgetting curve decay (w20)
|
||||
pub const DEFAULT_DECAY: f64 = 0.1542;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/// Clamp value to range
|
||||
#[inline]
|
||||
fn clamp(value: f64, min: f64, max: f64) -> f64 {
|
||||
value.clamp(min, max)
|
||||
}
|
||||
|
||||
/// Calculate forgetting curve factor based on w20
|
||||
/// FSRS-6: factor = 0.9^(-1/w20) - 1
|
||||
#[inline]
|
||||
fn forgetting_factor(w20: f64) -> f64 {
|
||||
0.9_f64.powf(-1.0 / w20) - 1.0
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RETRIEVABILITY (Probability of Recall)
|
||||
// ============================================================================
|
||||
|
||||
/// Calculate retrievability (probability of recall)
|
||||
///
|
||||
/// FSRS-6 formula: R = (1 + factor * t / S)^(-w20)
|
||||
/// where factor = 0.9^(-1/w20) - 1
|
||||
///
|
||||
/// This is the power forgetting curve - more accurate than exponential
|
||||
/// for modeling human memory.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stability` - Memory stability in days
|
||||
/// * `elapsed_days` - Days since last review
|
||||
///
|
||||
/// # Returns
|
||||
/// Probability of recall (0.0 to 1.0)
|
||||
pub fn retrievability(stability: f64, elapsed_days: f64) -> f64 {
|
||||
retrievability_with_decay(stability, elapsed_days, DEFAULT_DECAY)
|
||||
}
|
||||
|
||||
/// Retrievability with custom decay parameter (for personalization)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stability` - Memory stability in days
|
||||
/// * `elapsed_days` - Days since last review
|
||||
/// * `w20` - Forgetting curve decay parameter
|
||||
pub fn retrievability_with_decay(stability: f64, elapsed_days: f64, w20: f64) -> f64 {
|
||||
if stability <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if elapsed_days <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let factor = forgetting_factor(w20);
|
||||
let r = (1.0 + factor * elapsed_days / stability).powf(-w20);
|
||||
clamp(r, 0.0, 1.0)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INITIAL VALUES
|
||||
// ============================================================================
|
||||
|
||||
/// Calculate initial difficulty for a grade
|
||||
/// D0(G) = w4 - e^(w5*(G-1)) + 1
|
||||
pub fn initial_difficulty(grade: Rating) -> f64 {
|
||||
initial_difficulty_with_weights(grade, &FSRS6_WEIGHTS)
|
||||
}
|
||||
|
||||
/// Calculate initial difficulty with custom weights
|
||||
pub fn initial_difficulty_with_weights(grade: Rating, weights: &[f64; 21]) -> f64 {
|
||||
let w4 = weights[4];
|
||||
let w5 = weights[5];
|
||||
let g = grade.as_i32() as f64;
|
||||
let d = w4 - (w5 * (g - 1.0)).exp() + 1.0;
|
||||
clamp(d, MIN_DIFFICULTY, MAX_DIFFICULTY)
|
||||
}
|
||||
|
||||
/// Calculate initial stability for a grade
|
||||
/// S0(G) = w[G-1] (weights 0-3 are initial stabilities)
|
||||
pub fn initial_stability(grade: Rating) -> f64 {
|
||||
initial_stability_with_weights(grade, &FSRS6_WEIGHTS)
|
||||
}
|
||||
|
||||
/// Calculate initial stability with custom weights
|
||||
pub fn initial_stability_with_weights(grade: Rating, weights: &[f64; 21]) -> f64 {
|
||||
weights[grade.as_index()].max(MIN_STABILITY)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DIFFICULTY UPDATES
|
||||
// ============================================================================
|
||||
|
||||
/// Calculate next difficulty after review
|
||||
///
|
||||
/// FSRS-6 formula with mean reversion:
|
||||
/// D' = w7 * D0(3) + (1 - w7) * (D + delta * ((10 - D) / 9))
|
||||
/// where delta = -w6 * (G - 3)
|
||||
pub fn next_difficulty(current_d: f64, grade: Rating) -> f64 {
|
||||
next_difficulty_with_weights(current_d, grade, &FSRS6_WEIGHTS)
|
||||
}
|
||||
|
||||
/// Calculate next difficulty with custom weights
|
||||
pub fn next_difficulty_with_weights(current_d: f64, grade: Rating, weights: &[f64; 21]) -> f64 {
|
||||
let w6 = weights[6];
|
||||
let w7 = weights[7];
|
||||
let g = grade.as_i32() as f64;
|
||||
// FSRS-6 spec: Mean reversion target is D0(4) = initial difficulty for Easy
|
||||
let d0 = initial_difficulty_with_weights(Rating::Easy, weights);
|
||||
|
||||
// Delta based on grade deviation from "Good" (3)
|
||||
let delta = -w6 * (g - 3.0);
|
||||
|
||||
// FSRS-6: Apply mean reversion scaling ((10 - D) / 9)
|
||||
let mean_reversion_scale = (10.0 - current_d) / 9.0;
|
||||
let new_d = current_d + delta * mean_reversion_scale;
|
||||
|
||||
// Convex combination with initial difficulty for stability
|
||||
let final_d = w7 * d0 + (1.0 - w7) * new_d;
|
||||
clamp(final_d, MIN_DIFFICULTY, MAX_DIFFICULTY)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STABILITY UPDATES
|
||||
// ============================================================================
|
||||
|
||||
/// Calculate stability after successful recall
|
||||
///
|
||||
/// S' = S * (e^w8 * (11-D) * S^(-w9) * (e^(w10*(1-R)) - 1) * HP * EB + 1)
|
||||
pub fn next_recall_stability(current_s: f64, difficulty: f64, r: f64, grade: Rating) -> f64 {
|
||||
next_recall_stability_with_weights(current_s, difficulty, r, grade, &FSRS6_WEIGHTS)
|
||||
}
|
||||
|
||||
/// Calculate stability after successful recall with custom weights
|
||||
pub fn next_recall_stability_with_weights(
|
||||
current_s: f64,
|
||||
difficulty: f64,
|
||||
r: f64,
|
||||
grade: Rating,
|
||||
weights: &[f64; 21],
|
||||
) -> f64 {
|
||||
if grade == Rating::Again {
|
||||
return next_forget_stability_with_weights(difficulty, current_s, r, weights);
|
||||
}
|
||||
|
||||
let w8 = weights[8];
|
||||
let w9 = weights[9];
|
||||
let w10 = weights[10];
|
||||
let w15 = weights[15];
|
||||
let w16 = weights[16];
|
||||
|
||||
let hard_penalty = if grade == Rating::Hard { w15 } else { 1.0 };
|
||||
let easy_bonus = if grade == Rating::Easy { w16 } else { 1.0 };
|
||||
|
||||
let factor = w8.exp()
|
||||
* (11.0 - difficulty)
|
||||
* current_s.powf(-w9)
|
||||
* ((w10 * (1.0 - r)).exp() - 1.0)
|
||||
* hard_penalty
|
||||
* easy_bonus
|
||||
+ 1.0;
|
||||
|
||||
clamp(current_s * factor, MIN_STABILITY, MAX_STABILITY)
|
||||
}
|
||||
|
||||
/// Calculate stability after lapse (forgetting)
|
||||
///
|
||||
/// S'f = w11 * D^(-w12) * ((S+1)^w13 - 1) * e^(w14*(1-R))
|
||||
pub fn next_forget_stability(difficulty: f64, current_s: f64, r: f64) -> f64 {
|
||||
next_forget_stability_with_weights(difficulty, current_s, r, &FSRS6_WEIGHTS)
|
||||
}
|
||||
|
||||
/// Calculate stability after lapse with custom weights
|
||||
pub fn next_forget_stability_with_weights(
|
||||
difficulty: f64,
|
||||
current_s: f64,
|
||||
r: f64,
|
||||
weights: &[f64; 21],
|
||||
) -> f64 {
|
||||
let w11 = weights[11];
|
||||
let w12 = weights[12];
|
||||
let w13 = weights[13];
|
||||
let w14 = weights[14];
|
||||
|
||||
let new_s =
|
||||
w11 * difficulty.powf(-w12) * ((current_s + 1.0).powf(w13) - 1.0) * (w14 * (1.0 - r)).exp();
|
||||
|
||||
// FSRS-6 spec: Post-lapse stability cannot exceed pre-lapse stability
|
||||
let new_s = new_s.min(current_s);
|
||||
|
||||
clamp(new_s, MIN_STABILITY, MAX_STABILITY)
|
||||
}
|
||||
|
||||
/// Calculate stability for same-day reviews (NEW in FSRS-6)
|
||||
///
|
||||
/// S'(S,G) = S * e^(w17 * (G - 3 + w18)) * S^(-w19)
|
||||
pub fn same_day_stability(current_s: f64, grade: Rating) -> f64 {
|
||||
same_day_stability_with_weights(current_s, grade, &FSRS6_WEIGHTS)
|
||||
}
|
||||
|
||||
/// Calculate stability for same-day reviews with custom weights
|
||||
pub fn same_day_stability_with_weights(current_s: f64, grade: Rating, weights: &[f64; 21]) -> f64 {
|
||||
let w17 = weights[17];
|
||||
let w18 = weights[18];
|
||||
let w19 = weights[19];
|
||||
let g = grade.as_i32() as f64;
|
||||
|
||||
let new_s = current_s * (w17 * (g - 3.0 + w18)).exp() * current_s.powf(-w19);
|
||||
clamp(new_s, MIN_STABILITY, MAX_STABILITY)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INTERVAL CALCULATION
|
||||
// ============================================================================
|
||||
|
||||
/// Calculate next interval in days
|
||||
///
|
||||
/// FSRS-6 formula (inverse of retrievability):
|
||||
/// t = S / factor * (R^(-1/w20) - 1)
|
||||
pub fn next_interval(stability: f64, desired_retention: f64) -> i32 {
|
||||
next_interval_with_decay(stability, desired_retention, DEFAULT_DECAY)
|
||||
}
|
||||
|
||||
/// Calculate next interval with custom decay
|
||||
pub fn next_interval_with_decay(stability: f64, desired_retention: f64, w20: f64) -> i32 {
|
||||
if stability <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
if desired_retention >= 1.0 {
|
||||
return 0;
|
||||
}
|
||||
if desired_retention <= 0.0 {
|
||||
return MAX_STABILITY as i32;
|
||||
}
|
||||
|
||||
let factor = forgetting_factor(w20);
|
||||
let interval = stability / factor * (desired_retention.powf(-1.0 / w20) - 1.0);
|
||||
|
||||
interval.max(0.0).round() as i32
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FUZZING
|
||||
// ============================================================================
|
||||
|
||||
/// Apply interval fuzzing to prevent review clustering
|
||||
///
|
||||
/// Uses deterministic fuzzing based on a seed to ensure reproducibility.
|
||||
pub fn fuzz_interval(interval: i32, seed: u64) -> i32 {
|
||||
if interval <= 2 {
|
||||
return interval;
|
||||
}
|
||||
|
||||
// Use simple LCG for deterministic fuzzing
|
||||
let fuzz_range = (interval as f64 * 0.05).max(1.0) as i32;
|
||||
let random = ((seed.wrapping_mul(1103515245).wrapping_add(12345)) % 32768) as i32;
|
||||
let offset = (random % (2 * fuzz_range + 1)) - fuzz_range;
|
||||
|
||||
(interval + offset).max(1)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SENTIMENT BOOST
|
||||
// ============================================================================
|
||||
|
||||
/// Apply sentiment boost to stability (emotional memories last longer)
|
||||
///
|
||||
/// Research shows emotional memories are encoded more strongly due to
|
||||
/// amygdala modulation of hippocampal consolidation.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stability` - Current memory stability
|
||||
/// * `sentiment_intensity` - Emotional intensity (0.0 to 1.0)
|
||||
/// * `max_boost` - Maximum boost multiplier (typically 1.5 to 3.0)
|
||||
pub fn apply_sentiment_boost(stability: f64, sentiment_intensity: f64, max_boost: f64) -> f64 {
|
||||
let clamped_sentiment = clamp(sentiment_intensity, 0.0, 1.0);
|
||||
let clamped_max_boost = clamp(max_boost, 1.0, 3.0);
|
||||
let boost = 1.0 + (clamped_max_boost - 1.0) * clamped_sentiment;
|
||||
stability * boost
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn approx_eq(a: f64, b: f64, epsilon: f64) -> bool {
|
||||
(a - b).abs() < epsilon
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fsrs6_constants() {
|
||||
assert_eq!(FSRS6_WEIGHTS.len(), 21);
|
||||
assert!(FSRS6_WEIGHTS[20] > 0.0 && FSRS6_WEIGHTS[20] < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forgetting_factor() {
|
||||
let factor = forgetting_factor(DEFAULT_DECAY);
|
||||
assert!(factor > 0.0, "Factor should be positive");
|
||||
assert!(
|
||||
factor > 0.5 && factor < 5.0,
|
||||
"Expected factor between 0.5 and 5.0, got {}",
|
||||
factor
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retrievability_at_zero_days() {
|
||||
let r = retrievability(10.0, 0.0);
|
||||
assert_eq!(r, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retrievability_decreases_over_time() {
|
||||
let stability = 10.0;
|
||||
let r1 = retrievability(stability, 1.0);
|
||||
let r5 = retrievability(stability, 5.0);
|
||||
let r10 = retrievability(stability, 10.0);
|
||||
|
||||
assert!(r1 > r5);
|
||||
assert!(r5 > r10);
|
||||
assert!(r10 > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retrievability_with_custom_decay() {
|
||||
let stability = 10.0;
|
||||
let elapsed = 5.0;
|
||||
|
||||
let r_low_decay = retrievability_with_decay(stability, elapsed, 0.1);
|
||||
let r_high_decay = retrievability_with_decay(stability, elapsed, 0.5);
|
||||
|
||||
// Higher decay = faster forgetting (lower retrievability for same time)
|
||||
assert!(r_low_decay < r_high_decay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_interval_round_trip() {
|
||||
let stability = 15.0;
|
||||
let desired_retention = 0.9;
|
||||
|
||||
let interval = next_interval(stability, desired_retention);
|
||||
let actual_r = retrievability(stability, interval as f64);
|
||||
|
||||
assert!(
|
||||
approx_eq(actual_r, desired_retention, 0.05),
|
||||
"Round-trip: interval={}, R={}, desired={}",
|
||||
interval,
|
||||
actual_r,
|
||||
desired_retention
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initial_difficulty_order() {
|
||||
let d_again = initial_difficulty(Rating::Again);
|
||||
let d_hard = initial_difficulty(Rating::Hard);
|
||||
let d_good = initial_difficulty(Rating::Good);
|
||||
let d_easy = initial_difficulty(Rating::Easy);
|
||||
|
||||
assert!(d_again > d_hard);
|
||||
assert!(d_hard > d_good);
|
||||
assert!(d_good > d_easy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initial_difficulty_bounds() {
|
||||
for rating in [Rating::Again, Rating::Hard, Rating::Good, Rating::Easy] {
|
||||
let d = initial_difficulty(rating);
|
||||
assert!((MIN_DIFFICULTY..=MAX_DIFFICULTY).contains(&d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_difficulty_mean_reversion() {
|
||||
let high_d = 9.0;
|
||||
let new_d = next_difficulty(high_d, Rating::Good);
|
||||
assert!(new_d < high_d);
|
||||
|
||||
let low_d = 2.0;
|
||||
let new_d_low = next_difficulty(low_d, Rating::Again);
|
||||
assert!(new_d_low > low_d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_same_day_stability() {
|
||||
let current_s = 5.0;
|
||||
|
||||
let s_again = same_day_stability(current_s, Rating::Again);
|
||||
let s_good = same_day_stability(current_s, Rating::Good);
|
||||
let s_easy = same_day_stability(current_s, Rating::Easy);
|
||||
|
||||
assert!(s_again < s_good);
|
||||
assert!(s_good < s_easy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_interval() {
|
||||
let interval = 30;
|
||||
let fuzzed1 = fuzz_interval(interval, 12345);
|
||||
let fuzzed2 = fuzz_interval(interval, 12345);
|
||||
|
||||
// Same seed = same result (deterministic)
|
||||
assert_eq!(fuzzed1, fuzzed2);
|
||||
|
||||
// Fuzzing should keep it close
|
||||
assert!((fuzzed1 - interval).abs() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sentiment_boost() {
|
||||
let stability = 10.0;
|
||||
let boosted = apply_sentiment_boost(stability, 1.0, 2.0);
|
||||
assert_eq!(boosted, 20.0); // Full boost = 2x
|
||||
|
||||
let partial = apply_sentiment_boost(stability, 0.5, 2.0);
|
||||
assert_eq!(partial, 15.0); // 50% boost = 1.5x
|
||||
}
|
||||
}
|
||||
55
crates/vestige-core/src/fsrs/mod.rs
Normal file
55
crates/vestige-core/src/fsrs/mod.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//! FSRS-6 (Free Spaced Repetition Scheduler) Module
|
||||
//!
|
||||
//! The state-of-the-art spaced repetition algorithm (2025-2026).
|
||||
//! 20-30% more efficient than SM-2 (Anki's original algorithm).
|
||||
//!
|
||||
//! Reference: https://github.com/open-spaced-repetition/fsrs4anki
|
||||
//!
|
||||
//! ## Key improvements in FSRS-6 over FSRS-5:
|
||||
//! - 21 parameters (vs 19) with personalizable forgetting curve decay (w20)
|
||||
//! - Same-day review handling with S^(-w19) term
|
||||
//! - Better short-term memory modeling
|
||||
//!
|
||||
//! ## Core Formulas:
|
||||
//! - Retrievability: R = (1 + FACTOR * t / S)^(-w20) where FACTOR = 0.9^(-1/w20) - 1
|
||||
//! - Interval: t = S/FACTOR * (R^(1/w20) - 1)
|
||||
|
||||
mod algorithm;
|
||||
mod optimizer;
|
||||
mod scheduler;
|
||||
|
||||
pub use algorithm::{
|
||||
apply_sentiment_boost,
|
||||
fuzz_interval,
|
||||
initial_difficulty,
|
||||
initial_difficulty_with_weights,
|
||||
initial_stability,
|
||||
initial_stability_with_weights,
|
||||
next_difficulty,
|
||||
next_difficulty_with_weights,
|
||||
next_forget_stability,
|
||||
next_forget_stability_with_weights,
|
||||
next_interval,
|
||||
next_interval_with_decay,
|
||||
next_recall_stability,
|
||||
next_recall_stability_with_weights,
|
||||
// Core functions
|
||||
retrievability,
|
||||
retrievability_with_decay,
|
||||
same_day_stability,
|
||||
same_day_stability_with_weights,
|
||||
DEFAULT_DECAY,
|
||||
DEFAULT_RETENTION,
|
||||
// Constants
|
||||
FSRS6_WEIGHTS,
|
||||
MAX_DIFFICULTY,
|
||||
MAX_STABILITY,
|
||||
MIN_DIFFICULTY,
|
||||
MIN_STABILITY,
|
||||
};
|
||||
|
||||
pub use scheduler::{
|
||||
FSRSParameters, FSRSScheduler, FSRSState, LearningState, PreviewResults, Rating, ReviewResult,
|
||||
};
|
||||
|
||||
pub use optimizer::FSRSOptimizer;
|
||||
258
crates/vestige-core/src/fsrs/optimizer.rs
Normal file
258
crates/vestige-core/src/fsrs/optimizer.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
//! FSRS-6 Parameter Optimizer
|
||||
//!
|
||||
//! Personalizes FSRS parameters based on user review history.
|
||||
//! Uses gradient-free optimization to minimize prediction error.
|
||||
|
||||
use super::algorithm::{retrievability_with_decay, FSRS6_WEIGHTS};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
// ============================================================================
|
||||
// REVIEW LOG
|
||||
// ============================================================================
|
||||
|
||||
/// A single review event for optimization
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReviewLog {
|
||||
/// Review timestamp
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Rating given (1-4)
|
||||
pub rating: i32,
|
||||
/// Stability at time of review
|
||||
pub stability: f64,
|
||||
/// Difficulty at time of review
|
||||
pub difficulty: f64,
|
||||
/// Days since last review
|
||||
pub elapsed_days: f64,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPTIMIZER
|
||||
// ============================================================================
|
||||
|
||||
/// FSRS parameter optimizer
|
||||
///
|
||||
/// Personalizes the 21 FSRS-6 parameters based on user review history.
|
||||
/// Uses the RMSE (Root Mean Square Error) of retrievability predictions
|
||||
/// as the loss function.
|
||||
pub struct FSRSOptimizer {
|
||||
/// Current weights being optimized
|
||||
weights: [f64; 21],
|
||||
/// Review history for training
|
||||
reviews: Vec<ReviewLog>,
|
||||
/// Minimum reviews required for optimization
|
||||
min_reviews: usize,
|
||||
}
|
||||
|
||||
impl Default for FSRSOptimizer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FSRSOptimizer {
|
||||
/// Create a new optimizer with default weights
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
weights: FSRS6_WEIGHTS,
|
||||
reviews: Vec::new(),
|
||||
min_reviews: 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a review to the training history
|
||||
pub fn add_review(&mut self, review: ReviewLog) {
|
||||
self.reviews.push(review);
|
||||
}
|
||||
|
||||
/// Add multiple reviews
|
||||
pub fn add_reviews(&mut self, reviews: impl IntoIterator<Item = ReviewLog>) {
|
||||
self.reviews.extend(reviews);
|
||||
}
|
||||
|
||||
/// Get current weights
|
||||
pub fn weights(&self) -> &[f64; 21] {
|
||||
&self.weights
|
||||
}
|
||||
|
||||
/// Check if enough reviews for optimization
|
||||
pub fn has_enough_data(&self) -> bool {
|
||||
self.reviews.len() >= self.min_reviews
|
||||
}
|
||||
|
||||
/// Get the number of reviews in history
|
||||
pub fn review_count(&self) -> usize {
|
||||
self.reviews.len()
|
||||
}
|
||||
|
||||
/// Calculate RMSE loss for current weights
|
||||
pub fn calculate_loss(&self) -> f64 {
|
||||
if self.reviews.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let w20 = self.weights[20];
|
||||
let mut sum_squared_error = 0.0;
|
||||
|
||||
for review in &self.reviews {
|
||||
// Calculate predicted retrievability
|
||||
let predicted_r = retrievability_with_decay(review.stability, review.elapsed_days, w20);
|
||||
|
||||
// Convert rating to binary outcome (Again = 0, others = 1)
|
||||
let actual = if review.rating == 1 { 0.0 } else { 1.0 };
|
||||
|
||||
let error = predicted_r - actual;
|
||||
sum_squared_error += error * error;
|
||||
}
|
||||
|
||||
(sum_squared_error / self.reviews.len() as f64).sqrt()
|
||||
}
|
||||
|
||||
/// Optimize the forgetting curve decay parameter (w20)
|
||||
///
|
||||
/// This is the most personalizable parameter in FSRS-6.
|
||||
/// Uses golden section search for 1D optimization.
|
||||
pub fn optimize_decay(&mut self) -> f64 {
|
||||
if !self.has_enough_data() {
|
||||
return self.weights[20];
|
||||
}
|
||||
|
||||
let (mut a, mut b) = (0.01, 1.0);
|
||||
let phi = (1.0 + 5.0_f64.sqrt()) / 2.0;
|
||||
|
||||
let mut x1 = b - (b - a) / phi;
|
||||
let mut x2 = a + (b - a) / phi;
|
||||
|
||||
let mut f1 = self.loss_at_decay(x1);
|
||||
let mut f2 = self.loss_at_decay(x2);
|
||||
|
||||
// Golden section iterations
|
||||
for _ in 0..50 {
|
||||
if f1 < f2 {
|
||||
b = x2;
|
||||
x2 = x1;
|
||||
f2 = f1;
|
||||
x1 = b - (b - a) / phi;
|
||||
f1 = self.loss_at_decay(x1);
|
||||
} else {
|
||||
a = x1;
|
||||
x1 = x2;
|
||||
f1 = f2;
|
||||
x2 = a + (b - a) / phi;
|
||||
f2 = self.loss_at_decay(x2);
|
||||
}
|
||||
|
||||
if (b - a).abs() < 0.001 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let optimal_decay = (a + b) / 2.0;
|
||||
self.weights[20] = optimal_decay;
|
||||
optimal_decay
|
||||
}
|
||||
|
||||
/// Calculate loss at a specific decay value
|
||||
fn loss_at_decay(&self, decay: f64) -> f64 {
|
||||
if self.reviews.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut sum_squared_error = 0.0;
|
||||
|
||||
for review in &self.reviews {
|
||||
let predicted_r =
|
||||
retrievability_with_decay(review.stability, review.elapsed_days, decay);
|
||||
|
||||
let actual = if review.rating == 1 { 0.0 } else { 1.0 };
|
||||
let error = predicted_r - actual;
|
||||
sum_squared_error += error * error;
|
||||
}
|
||||
|
||||
(sum_squared_error / self.reviews.len() as f64).sqrt()
|
||||
}
|
||||
|
||||
/// Reset optimizer state
|
||||
pub fn reset(&mut self) {
|
||||
self.weights = FSRS6_WEIGHTS;
|
||||
self.reviews.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
|
||||
fn create_test_reviews(count: usize) -> Vec<ReviewLog> {
|
||||
let now = Utc::now();
|
||||
(0..count)
|
||||
.map(|i| ReviewLog {
|
||||
timestamp: now - Duration::days(i as i64),
|
||||
rating: if i % 5 == 0 { 1 } else { 3 },
|
||||
stability: 5.0 + (i as f64 * 0.1),
|
||||
difficulty: 5.0,
|
||||
elapsed_days: 1.0 + (i as f64 * 0.5),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optimizer_creation() {
|
||||
let optimizer = FSRSOptimizer::new();
|
||||
assert_eq!(optimizer.weights().len(), 21);
|
||||
assert!(!optimizer.has_enough_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_reviews() {
|
||||
let mut optimizer = FSRSOptimizer::new();
|
||||
let reviews = create_test_reviews(50);
|
||||
|
||||
optimizer.add_reviews(reviews);
|
||||
assert_eq!(optimizer.review_count(), 50);
|
||||
assert!(!optimizer.has_enough_data()); // Need 100
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_loss() {
|
||||
let mut optimizer = FSRSOptimizer::new();
|
||||
let reviews = create_test_reviews(100);
|
||||
optimizer.add_reviews(reviews);
|
||||
|
||||
let loss = optimizer.calculate_loss();
|
||||
assert!(loss >= 0.0);
|
||||
assert!(loss <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optimize_decay() {
|
||||
let mut optimizer = FSRSOptimizer::new();
|
||||
let reviews = create_test_reviews(200);
|
||||
optimizer.add_reviews(reviews);
|
||||
|
||||
let original_decay = optimizer.weights()[20];
|
||||
let optimized_decay = optimizer.optimize_decay();
|
||||
|
||||
// Decay should be a reasonable value
|
||||
assert!(optimized_decay > 0.01);
|
||||
assert!(optimized_decay < 1.0);
|
||||
|
||||
// Optimization should have changed the value
|
||||
assert_ne!(original_decay, optimized_decay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset() {
|
||||
let mut optimizer = FSRSOptimizer::new();
|
||||
let reviews = create_test_reviews(100);
|
||||
optimizer.add_reviews(reviews);
|
||||
|
||||
optimizer.reset();
|
||||
assert_eq!(optimizer.review_count(), 0);
|
||||
assert_eq!(optimizer.weights()[20], FSRS6_WEIGHTS[20]);
|
||||
}
|
||||
}
|
||||
479
crates/vestige-core/src/fsrs/scheduler.rs
Normal file
479
crates/vestige-core/src/fsrs/scheduler.rs
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
//! FSRS-6 Scheduler
|
||||
//!
|
||||
//! High-level scheduler that manages review state and produces
|
||||
//! optimal scheduling decisions.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::algorithm::{
|
||||
apply_sentiment_boost, fuzz_interval, initial_difficulty_with_weights,
|
||||
initial_stability_with_weights, next_difficulty_with_weights,
|
||||
next_forget_stability_with_weights, next_interval_with_decay,
|
||||
next_recall_stability_with_weights, retrievability_with_decay, same_day_stability_with_weights,
|
||||
DEFAULT_RETENTION, FSRS6_WEIGHTS, MAX_STABILITY,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
/// Review ratings (1-4 scale)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Rating {
|
||||
/// Complete failure to recall
|
||||
Again = 1,
|
||||
/// Recalled with significant difficulty
|
||||
Hard = 2,
|
||||
/// Recalled with some effort
|
||||
Good = 3,
|
||||
/// Instant, effortless recall
|
||||
Easy = 4,
|
||||
}
|
||||
|
||||
impl Rating {
|
||||
/// Convert to i32
|
||||
pub fn as_i32(&self) -> i32 {
|
||||
*self as i32
|
||||
}
|
||||
|
||||
/// Create from i32
|
||||
pub fn from_i32(value: i32) -> Option<Self> {
|
||||
match value {
|
||||
1 => Some(Rating::Again),
|
||||
2 => Some(Rating::Hard),
|
||||
3 => Some(Rating::Good),
|
||||
4 => Some(Rating::Easy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get 0-indexed position (for accessing weights array)
|
||||
pub fn as_index(&self) -> usize {
|
||||
(*self as usize) - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Learning states in the FSRS state machine
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum LearningState {
|
||||
/// Never reviewed
|
||||
#[default]
|
||||
New,
|
||||
/// In initial learning phase
|
||||
Learning,
|
||||
/// Graduated to review phase
|
||||
Review,
|
||||
/// Failed review, relearning
|
||||
Relearning,
|
||||
}
|
||||
|
||||
/// FSRS-6 card state
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FSRSState {
|
||||
/// Memory difficulty (1.0 to 10.0)
|
||||
pub difficulty: f64,
|
||||
/// Memory stability in days
|
||||
pub stability: f64,
|
||||
/// Current learning state
|
||||
pub state: LearningState,
|
||||
/// Number of successful reviews
|
||||
pub reps: i32,
|
||||
/// Number of lapses
|
||||
pub lapses: i32,
|
||||
/// Last review timestamp
|
||||
pub last_review: DateTime<Utc>,
|
||||
/// Days until next review
|
||||
pub scheduled_days: i32,
|
||||
}
|
||||
|
||||
impl Default for FSRSState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
difficulty: super::algorithm::initial_difficulty(Rating::Good),
|
||||
stability: super::algorithm::initial_stability(Rating::Good),
|
||||
state: LearningState::New,
|
||||
reps: 0,
|
||||
lapses: 0,
|
||||
last_review: Utc::now(),
|
||||
scheduled_days: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a review operation
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReviewResult {
|
||||
/// Updated state after review
|
||||
pub state: FSRSState,
|
||||
/// Current retrievability before review
|
||||
pub retrievability: f64,
|
||||
/// Scheduled interval in days
|
||||
pub interval: i32,
|
||||
/// Whether this was a lapse (forgotten after learning)
|
||||
pub is_lapse: bool,
|
||||
}
|
||||
|
||||
/// Preview results for all grades
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreviewResults {
|
||||
/// Result if rated Again
|
||||
pub again: ReviewResult,
|
||||
/// Result if rated Hard
|
||||
pub hard: ReviewResult,
|
||||
/// Result if rated Good
|
||||
pub good: ReviewResult,
|
||||
/// Result if rated Easy
|
||||
pub easy: ReviewResult,
|
||||
}
|
||||
|
||||
/// User-personalizable FSRS parameters
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FSRSParameters {
|
||||
/// FSRS-6 weights (21 parameters)
|
||||
pub weights: [f64; 21],
|
||||
/// Target retention rate (default 0.9)
|
||||
pub desired_retention: f64,
|
||||
/// Maximum interval in days
|
||||
pub max_interval: i32,
|
||||
/// Enable interval fuzzing
|
||||
pub enable_fuzz: bool,
|
||||
}
|
||||
|
||||
impl Default for FSRSParameters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
weights: FSRS6_WEIGHTS,
|
||||
desired_retention: DEFAULT_RETENTION,
|
||||
max_interval: MAX_STABILITY as i32,
|
||||
enable_fuzz: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SCHEDULER
|
||||
// ============================================================================
|
||||
|
||||
/// FSRS-6 Scheduler
|
||||
///
|
||||
/// Manages spaced repetition scheduling using the FSRS-6 algorithm.
|
||||
pub struct FSRSScheduler {
|
||||
params: FSRSParameters,
|
||||
enable_sentiment_boost: bool,
|
||||
max_sentiment_boost: f64,
|
||||
}
|
||||
|
||||
impl Default for FSRSScheduler {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
params: FSRSParameters::default(),
|
||||
enable_sentiment_boost: true,
|
||||
max_sentiment_boost: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FSRSScheduler {
|
||||
/// Create a new scheduler with custom parameters
|
||||
pub fn new(params: FSRSParameters) -> Self {
|
||||
Self {
|
||||
params,
|
||||
enable_sentiment_boost: true,
|
||||
max_sentiment_boost: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure sentiment boost settings
|
||||
pub fn with_sentiment_boost(mut self, enable: bool, max_boost: f64) -> Self {
|
||||
self.enable_sentiment_boost = enable;
|
||||
self.max_sentiment_boost = max_boost;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a new card in the initial state
|
||||
pub fn new_card(&self) -> FSRSState {
|
||||
FSRSState::default()
|
||||
}
|
||||
|
||||
/// Process a review and return the updated state
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `state` - Current card state
|
||||
/// * `grade` - User's rating of the review
|
||||
/// * `elapsed_days` - Days since last review
|
||||
/// * `sentiment_boost` - Optional sentiment intensity for emotional memories
|
||||
pub fn review(
|
||||
&self,
|
||||
state: &FSRSState,
|
||||
grade: Rating,
|
||||
elapsed_days: f64,
|
||||
sentiment_boost: Option<f64>,
|
||||
) -> ReviewResult {
|
||||
let w20 = self.params.weights[20];
|
||||
|
||||
let r = if state.state == LearningState::New {
|
||||
1.0
|
||||
} else {
|
||||
retrievability_with_decay(state.stability, elapsed_days.max(0.0), w20)
|
||||
};
|
||||
|
||||
// Check if this is a same-day review (less than 1 day elapsed)
|
||||
let is_same_day = elapsed_days < 1.0 && state.state != LearningState::New;
|
||||
|
||||
let (mut new_state, is_lapse) = if state.state == LearningState::New {
|
||||
(self.handle_first_review(state, grade), false)
|
||||
} else if is_same_day {
|
||||
(self.handle_same_day_review(state, grade), false)
|
||||
} else if grade == Rating::Again {
|
||||
let is_lapse =
|
||||
state.state == LearningState::Review || state.state == LearningState::Relearning;
|
||||
(self.handle_lapse(state, r), is_lapse)
|
||||
} else {
|
||||
(self.handle_recall(state, grade, r), false)
|
||||
};
|
||||
|
||||
// Apply sentiment boost
|
||||
if self.enable_sentiment_boost {
|
||||
if let Some(sentiment) = sentiment_boost {
|
||||
if sentiment > 0.0 {
|
||||
new_state.stability = apply_sentiment_boost(
|
||||
new_state.stability,
|
||||
sentiment,
|
||||
self.max_sentiment_boost,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut interval =
|
||||
next_interval_with_decay(new_state.stability, self.params.desired_retention, w20)
|
||||
.min(self.params.max_interval);
|
||||
|
||||
// Apply fuzzing
|
||||
if self.params.enable_fuzz && interval > 2 {
|
||||
let seed = state.last_review.timestamp() as u64;
|
||||
interval = fuzz_interval(interval, seed);
|
||||
}
|
||||
|
||||
new_state.scheduled_days = interval;
|
||||
new_state.last_review = Utc::now();
|
||||
|
||||
ReviewResult {
|
||||
state: new_state,
|
||||
retrievability: r,
|
||||
interval,
|
||||
is_lapse,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_first_review(&self, state: &FSRSState, grade: Rating) -> FSRSState {
|
||||
let weights = &self.params.weights;
|
||||
let d = initial_difficulty_with_weights(grade, weights);
|
||||
let s = initial_stability_with_weights(grade, weights);
|
||||
|
||||
let new_state = match grade {
|
||||
Rating::Again | Rating::Hard => LearningState::Learning,
|
||||
_ => LearningState::Review,
|
||||
};
|
||||
|
||||
FSRSState {
|
||||
difficulty: d,
|
||||
stability: s,
|
||||
state: new_state,
|
||||
reps: 1,
|
||||
lapses: if grade == Rating::Again { 1 } else { 0 },
|
||||
last_review: state.last_review,
|
||||
scheduled_days: state.scheduled_days,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_same_day_review(&self, state: &FSRSState, grade: Rating) -> FSRSState {
|
||||
let weights = &self.params.weights;
|
||||
let new_s = same_day_stability_with_weights(state.stability, grade, weights);
|
||||
let new_d = next_difficulty_with_weights(state.difficulty, grade, weights);
|
||||
|
||||
FSRSState {
|
||||
difficulty: new_d,
|
||||
stability: new_s,
|
||||
state: state.state,
|
||||
reps: state.reps + 1,
|
||||
lapses: state.lapses,
|
||||
last_review: state.last_review,
|
||||
scheduled_days: state.scheduled_days,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_lapse(&self, state: &FSRSState, r: f64) -> FSRSState {
|
||||
let weights = &self.params.weights;
|
||||
let new_s =
|
||||
next_forget_stability_with_weights(state.difficulty, state.stability, r, weights);
|
||||
let new_d = next_difficulty_with_weights(state.difficulty, Rating::Again, weights);
|
||||
|
||||
FSRSState {
|
||||
difficulty: new_d,
|
||||
stability: new_s,
|
||||
state: LearningState::Relearning,
|
||||
reps: state.reps + 1,
|
||||
lapses: state.lapses + 1,
|
||||
last_review: state.last_review,
|
||||
scheduled_days: state.scheduled_days,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_recall(&self, state: &FSRSState, grade: Rating, r: f64) -> FSRSState {
|
||||
let weights = &self.params.weights;
|
||||
let new_s = next_recall_stability_with_weights(
|
||||
state.stability,
|
||||
state.difficulty,
|
||||
r,
|
||||
grade,
|
||||
weights,
|
||||
);
|
||||
let new_d = next_difficulty_with_weights(state.difficulty, grade, weights);
|
||||
|
||||
FSRSState {
|
||||
difficulty: new_d,
|
||||
stability: new_s,
|
||||
state: LearningState::Review,
|
||||
reps: state.reps + 1,
|
||||
lapses: state.lapses,
|
||||
last_review: state.last_review,
|
||||
scheduled_days: state.scheduled_days,
|
||||
}
|
||||
}
|
||||
|
||||
/// Preview what would happen for each rating
|
||||
pub fn preview_reviews(&self, state: &FSRSState, elapsed_days: f64) -> PreviewResults {
|
||||
PreviewResults {
|
||||
again: self.review(state, Rating::Again, elapsed_days, None),
|
||||
hard: self.review(state, Rating::Hard, elapsed_days, None),
|
||||
good: self.review(state, Rating::Good, elapsed_days, None),
|
||||
easy: self.review(state, Rating::Easy, elapsed_days, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate days since last review
|
||||
pub fn days_since_review(&self, last_review: &DateTime<Utc>) -> f64 {
|
||||
let now = Utc::now();
|
||||
let diff = now.signed_duration_since(*last_review);
|
||||
(diff.num_seconds() as f64 / 86400.0).max(0.0)
|
||||
}
|
||||
|
||||
/// Get the personalized forgetting curve decay parameter
|
||||
pub fn get_decay(&self) -> f64 {
|
||||
self.params.weights[20]
|
||||
}
|
||||
|
||||
/// Update weights for personalization (after training on user data)
|
||||
pub fn set_weights(&mut self, weights: [f64; 21]) {
|
||||
self.params.weights = weights;
|
||||
}
|
||||
|
||||
/// Get current parameters
|
||||
pub fn params(&self) -> &FSRSParameters {
|
||||
&self.params
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_first_review() {
|
||||
let scheduler = FSRSScheduler::default();
|
||||
let card = scheduler.new_card();
|
||||
|
||||
let result = scheduler.review(&card, Rating::Good, 0.0, None);
|
||||
|
||||
assert_eq!(result.state.reps, 1);
|
||||
assert_eq!(result.state.lapses, 0);
|
||||
assert_eq!(result.state.state, LearningState::Review);
|
||||
assert!(result.interval > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_lapse_tracking() {
|
||||
let scheduler = FSRSScheduler::default();
|
||||
let mut card = scheduler.new_card();
|
||||
|
||||
let result = scheduler.review(&card, Rating::Good, 0.0, None);
|
||||
card = result.state;
|
||||
assert_eq!(card.lapses, 0);
|
||||
|
||||
let result = scheduler.review(&card, Rating::Again, 1.0, None);
|
||||
assert!(result.is_lapse);
|
||||
assert_eq!(result.state.lapses, 1);
|
||||
assert_eq!(result.state.state, LearningState::Relearning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_same_day_review() {
|
||||
let scheduler = FSRSScheduler::default();
|
||||
let mut card = scheduler.new_card();
|
||||
|
||||
// First review
|
||||
let result = scheduler.review(&card, Rating::Good, 0.0, None);
|
||||
card = result.state;
|
||||
let initial_stability = card.stability;
|
||||
|
||||
// Same-day review (0.5 days later)
|
||||
let result = scheduler.review(&card, Rating::Good, 0.5, None);
|
||||
|
||||
// Should use same-day formula, not regular recall
|
||||
assert!(result.state.stability != initial_stability);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_parameters() {
|
||||
let mut params = FSRSParameters::default();
|
||||
params.desired_retention = 0.85;
|
||||
params.enable_fuzz = false;
|
||||
|
||||
let scheduler = FSRSScheduler::new(params);
|
||||
let card = scheduler.new_card();
|
||||
let result = scheduler.review(&card, Rating::Good, 0.0, None);
|
||||
|
||||
// Lower retention = longer intervals
|
||||
let default_scheduler = FSRSScheduler::default();
|
||||
let default_result = default_scheduler.review(&card, Rating::Good, 0.0, None);
|
||||
|
||||
assert!(result.interval > default_result.interval);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rating_conversion() {
|
||||
assert_eq!(Rating::Again.as_i32(), 1);
|
||||
assert_eq!(Rating::Hard.as_i32(), 2);
|
||||
assert_eq!(Rating::Good.as_i32(), 3);
|
||||
assert_eq!(Rating::Easy.as_i32(), 4);
|
||||
|
||||
assert_eq!(Rating::from_i32(1), Some(Rating::Again));
|
||||
assert_eq!(Rating::from_i32(5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_reviews() {
|
||||
let scheduler = FSRSScheduler::default();
|
||||
let card = scheduler.new_card();
|
||||
|
||||
let preview = scheduler.preview_reviews(&card, 0.0);
|
||||
|
||||
// Again should have shortest interval
|
||||
assert!(preview.again.interval < preview.good.interval);
|
||||
// Easy should have longest interval
|
||||
assert!(preview.easy.interval > preview.good.interval);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue