use crate::memory_core::decay::DecayConfig;
use crate::memory_core::palace::Drawer;
use crate::memory_core::retrieval::PalaceHandle;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
const PREVIEW_MAX_CHARS: usize = 120;
#[derive(Debug, Clone, PartialEq)]
pub struct FadingParams {
pub resurface_min_base: f32,
pub resurface_threshold: f32,
pub resurface_boost_epsilon: f32,
pub resurface_top_n: usize,
}
impl Default for FadingParams {
fn default() -> Self {
Self {
resurface_min_base: 0.7,
resurface_threshold: 0.3,
resurface_boost_epsilon: 0.01,
resurface_top_n: 10,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FadingMemory {
pub drawer_id: Uuid,
pub base_importance: f32,
pub effective_importance: f32,
pub age_days: f32,
pub content_preview: String,
}
fn preview(content: &str) -> String {
if content.chars().count() <= PREVIEW_MAX_CHARS {
return content.to_string();
}
let truncated: String = content.chars().take(PREVIEW_MAX_CHARS).collect();
format!("{truncated}…")
}
pub fn rank_fading(
drawers: &[Drawer],
decay: &DecayConfig,
params: &FadingParams,
) -> Vec<FadingMemory> {
if params.resurface_top_n == 0 {
return Vec::new();
}
let mut fading: Vec<FadingMemory> = drawers
.iter()
.filter(|d| !d.drawer_type.is_protected())
.filter(|d| d.importance >= params.resurface_min_base)
.filter_map(|d| {
let age_days = DecayConfig::age_days(d.created_at);
let boost = d.accumulated_boost(decay);
if boost >= params.resurface_boost_epsilon {
return None;
}
let effective = decay.effective_importance(d.importance, age_days, boost);
if effective >= params.resurface_threshold {
return None;
}
Some(FadingMemory {
drawer_id: d.id,
base_importance: d.importance,
effective_importance: effective,
age_days,
content_preview: preview(&d.content),
})
})
.collect();
fading.sort_by(|a, b| {
b.base_importance
.partial_cmp(&a.base_importance)
.unwrap_or(std::cmp::Ordering::Equal)
.then(
b.age_days
.partial_cmp(&a.age_days)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
fading.truncate(params.resurface_top_n);
fading
}
pub fn detect_fading(handle: &Arc<PalaceHandle>, params: &FadingParams) -> Vec<FadingMemory> {
rank_fading(&handle.drawers.read(), &handle.decay_config, params)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory_core::palace::DrawerType;
fn aged_drawer(importance: f32, age_days: i64) -> Drawer {
let mut d = Drawer::new(Uuid::new_v4(), "an important thing worth remembering");
d.importance = importance;
d.created_at = chrono::Utc::now() - chrono::Duration::days(age_days);
d
}
#[test]
fn default_params_match_spec() {
let p = FadingParams::default();
assert_eq!(p.resurface_min_base, 0.7);
assert_eq!(p.resurface_threshold, 0.3);
assert_eq!(p.resurface_boost_epsilon, 0.01);
assert_eq!(p.resurface_top_n, 10);
}
#[test]
fn flags_high_base_aged_unaccessed_drawer() {
let decay = DecayConfig::default();
let params = FadingParams::default();
let d = aged_drawer(0.8, 360);
let out = rank_fading(&[d], &decay, ¶ms);
assert_eq!(out.len(), 1, "high-base aged unaccessed drawer should flag");
assert!(out[0].effective_importance < params.resurface_threshold);
assert_eq!(out[0].base_importance, 0.8);
}
#[test]
fn rejects_fresh_drawer() {
let decay = DecayConfig::default();
let params = FadingParams::default();
let d = aged_drawer(0.8, 0);
assert!(rank_fading(&[d], &decay, ¶ms).is_empty());
}
#[test]
fn rejects_low_base_drawer() {
let decay = DecayConfig::default();
let params = FadingParams::default();
let d = aged_drawer(0.5, 360);
assert!(rank_fading(&[d], &decay, ¶ms).is_empty());
}
#[test]
fn rejects_recently_boosted_drawer() {
let decay = DecayConfig::default();
let params = FadingParams::default();
let mut d = aged_drawer(0.8, 360);
d.record_access();
assert!(
d.accumulated_boost(&decay) >= params.resurface_boost_epsilon,
"one access should exceed the tiny epsilon"
);
assert!(
rank_fading(&[d], &decay, ¶ms).is_empty(),
"a reinforced drawer is not fading"
);
}
#[test]
fn rejects_protected_task_drawer() {
let decay = DecayConfig::default();
let params = FadingParams::default();
let mut d = aged_drawer(0.9, 360);
d.drawer_type = DrawerType::Task;
assert!(
rank_fading(&[d], &decay, ¶ms).is_empty(),
"protected Task drawers are never resurfaced"
);
}
#[test]
fn ranks_and_caps() {
let decay = DecayConfig::default();
let params = FadingParams {
resurface_top_n: 2,
..FadingParams::default()
};
let a = aged_drawer(0.75, 360);
let b = aged_drawer(0.95, 360);
let c = aged_drawer(0.85, 360);
let out = rank_fading(&[a, b, c], &decay, ¶ms);
assert_eq!(out.len(), 2, "capped at top_n");
assert_eq!(out[0].base_importance, 0.95, "highest base first");
assert_eq!(out[1].base_importance, 0.85);
}
#[test]
fn top_n_zero_returns_empty() {
let decay = DecayConfig::default();
let params = FadingParams {
resurface_top_n: 0,
..FadingParams::default()
};
let d = aged_drawer(0.8, 360);
assert!(rank_fading(&[d], &decay, ¶ms).is_empty());
}
#[test]
fn preview_truncates_long_content() {
let long = "x".repeat(PREVIEW_MAX_CHARS + 50);
let p = preview(&long);
assert_eq!(p.chars().count(), PREVIEW_MAX_CHARS + 1, "chars + ellipsis");
assert!(p.ends_with('…'));
}
#[test]
fn boundary_conditions_table() {
let params = FadingParams::default();
let decay = DecayConfig::default();
let base_boundary = aged_drawer(params.resurface_min_base, 360);
let out = rank_fading(&[base_boundary], &decay, ¶ms);
assert_eq!(
out.len(),
1,
"base == resurface_min_base must be a candidate (>= is inclusive)"
);
let decay_floor_at_threshold = DecayConfig {
floor: params.resurface_threshold,
..DecayConfig::default()
};
let eff_at_boundary = aged_drawer(0.7, 360);
let out = rank_fading(&[eff_at_boundary], &decay_floor_at_threshold, ¶ms);
assert!(
out.is_empty(),
"effective == resurface_threshold must NOT be fading (< is exclusive)"
);
let decay_floor_below_threshold = DecayConfig {
floor: 0.29,
..DecayConfig::default()
};
let eff_below_boundary = aged_drawer(0.7, 360);
let out = rank_fading(&[eff_below_boundary], &decay_floor_below_threshold, ¶ms);
assert_eq!(
out.len(),
1,
"effective just below resurface_threshold must be fading"
);
let decay_boost_at_epsilon = DecayConfig {
access_boost: params.resurface_boost_epsilon,
..DecayConfig::default()
};
let mut boost_at_boundary = aged_drawer(0.7, 360);
boost_at_boundary.record_access(); assert_eq!(
boost_at_boundary.accumulated_boost(&decay_boost_at_epsilon),
0.01,
"sanity: boost lands bit-exactly on the epsilon"
);
let out = rank_fading(&[boost_at_boundary], &decay_boost_at_epsilon, ¶ms);
assert!(
out.is_empty(),
"accumulated_boost == resurface_boost_epsilon must NOT be fading (< is exclusive)"
);
let decay_boost_below_epsilon = DecayConfig {
access_boost: 0.009,
..DecayConfig::default()
};
let mut boost_below_boundary = aged_drawer(0.7, 360);
boost_below_boundary.record_access();
let out = rank_fading(&[boost_below_boundary], &decay_boost_below_epsilon, ¶ms);
assert_eq!(
out.len(),
1,
"accumulated_boost just below resurface_boost_epsilon must be fading"
);
}
#[test]
fn rank_fading_honours_custom_half_life_override() {
let params = FadingParams::default();
let short_half_life = DecayConfig {
half_life_days: 30.0,
..DecayConfig::default()
};
let default_decay = DecayConfig::default();
let d_short = aged_drawer(0.8, 90);
let d_default = aged_drawer(0.8, 90);
let out_short = rank_fading(&[d_short], &short_half_life, ¶ms);
assert_eq!(
out_short.len(),
1,
"shorter half-life override must decay far enough to flag"
);
assert!(out_short[0].effective_importance < params.resurface_threshold);
let out_default = rank_fading(&[d_default], &default_decay, ¶ms);
assert!(
out_default.is_empty(),
"default half-life must not yet have decayed enough to flag at the same age"
);
}
}