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:
Sam Valladares 2026-01-25 01:31:03 -06:00
commit f9c60eb5a7
169 changed files with 97206 additions and 0 deletions

View 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
}
}

View 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;

View 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]);
}
}

View 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);
}
}