mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-09 07:42:37 +02:00
First AI memory system to model forgetting as a neuroscience-grounded PROCESS rather than passive decay. Adds the `suppress` MCP tool (#24), Rac1 cascade worker, migration V10, and dashboard forgetting indicators. Based on: - Anderson, Hanslmayr & Quaegebeur (2025), Nat Rev Neurosci — right lateral PFC as the domain-general inhibitory controller; SIF compounds with each stopping attempt. - Cervantes-Sandoval et al. (2020), Front Cell Neurosci PMC7477079 — Rac1 GTPase as the active synaptic destabilization mechanism. What's new: * `suppress` MCP tool — each call compounds `suppression_count` and subtracts a `0.15 × count` penalty (saturating at 80%) from retrieval scores during hybrid search. Distinct from delete (removes) and demote (one-shot). * Rac1 cascade worker — background sweep piggybacks the 6h consolidation loop, walks `memory_connections` edges from recently-suppressed seeds, applies attenuated FSRS decay to co-activated neighbors. You don't just forget Jake — you fade the café, the roommate, the birthday. * 24h labile window — reversible via `suppress({id, reverse: true})` within 24 hours. Matches Nader reconsolidation semantics. * Migration V10 — additive-only (`suppression_count`, `suppressed_at` + partial indices). All v2.0.x DBs upgrade seamlessly on first launch. * Dashboard: `ForgettingIndicator.svelte` pulses when suppressions are active. 3D graph nodes dim to 20% opacity when suppressed. New WebSocket events: `MemorySuppressed`, `MemoryUnsuppressed`, `Rac1CascadeSwept`. Heartbeat carries `suppressed_count`. * Search pipeline: SIF penalty inserted into the accessibility stage so it stacks on top of passive FSRS decay. * Tool count bumped 23 → 24. Cognitive modules 29 → 30. Memories persist — they are INHIBITED, not erased. `memory.get(id)` returns full content through any number of suppressions. The 24h labile window is a grace period for regret. Also fixes issue #31 (dashboard graph view buggy) as a companion UI bug discovered during the v2.0.5 audit cycle: * Root cause: node glow `SpriteMaterial` had no `map`, so `THREE.Sprite` rendered as a solid-coloured 1×1 plane. Additive blending + `UnrealBloomPass(0.8, 0.4, 0.85)` amplified the square edges into hard-edged glowing cubes. * Fix: shared 128×128 radial-gradient `CanvasTexture` singleton used as the sprite map. Retuned bloom to `(0.55, 0.6, 0.2)`. Halved fog density (0.008 → 0.0035). Edges bumped from dark navy `0x4a4a7a` to brand violet `0x8b5cf6` with higher opacity. Added explicit `scene.background` and a 2000-point starfield for depth. * 21 regression tests added in `ui-fixes.test.ts` locking every invariant in (shared texture singleton, depthWrite:false, scale ×6, bloom magic numbers via source regex, starfield presence). Tests: 1,284 Rust (+47) + 171 Vitest (+21) = 1,455 total, 0 failed Clippy: clean across all targets, zero warnings Release binary: 22.6MB, `cargo build --release -p vestige-mcp` green Versions: workspace aligned at 2.0.5 across all 6 crates/packages Closes #31
476 lines
14 KiB
Rust
476 lines
14 KiB
Rust
//! 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::{
|
|
DEFAULT_RETENTION, FSRS6_WEIGHTS, MAX_STABILITY, 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,
|
|
};
|
|
|
|
// ============================================================================
|
|
// 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
|
|
&& let Some(sentiment) = sentiment_boost
|
|
&& 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 params = FSRSParameters {
|
|
desired_retention: 0.85,
|
|
enable_fuzz: false,
|
|
..FSRSParameters::default()
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|