use rusqlite::{Connection, params};
pub const ENV_DECAY: &str = "AI_MEMORY_CONFIDENCE_DECAY";
#[must_use]
pub fn decay_enabled() -> bool {
std::env::var(ENV_DECAY).is_ok_and(|v| v == "1")
}
static NAMESPACE_HALF_LIFE: std::sync::OnceLock<std::collections::HashMap<String, f64>> =
std::sync::OnceLock::new();
pub fn set_namespace_half_life_overrides(overrides: std::collections::HashMap<String, f64>) {
let _ = NAMESPACE_HALF_LIFE.set(overrides);
}
#[must_use]
pub fn namespace_half_life_overrides() -> Option<&'static std::collections::HashMap<String, f64>> {
NAMESPACE_HALF_LIFE.get().filter(|m| !m.is_empty())
}
#[must_use]
pub fn half_life_for_namespace(namespace: &str) -> f64 {
NAMESPACE_HALF_LIFE
.get()
.and_then(|m| m.get(namespace))
.copied()
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(crate::confidence::DEFAULT_HALF_LIFE_DAYS)
}
#[must_use]
pub fn decayed(base: f64, age_days: f64, half_life_days: f64) -> f64 {
let age = age_days.max(0.0);
let half_life = half_life_days.max(f64::EPSILON);
let factor = (-age * std::f64::consts::LN_2 / half_life).exp();
(base * factor).clamp(0.0, 1.0)
}
pub fn apply_decay_touch(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
use chrono::{DateTime, Utc};
let row: Option<(f64, String, Option<String>, String)> = conn
.query_row(
"SELECT confidence, created_at, confidence_decayed_at, namespace
FROM memories WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.ok();
let Some((current_confidence, created_at, decayed_at, namespace)) = row else {
return Ok(false);
};
let now = Utc::now();
let anchor_str = decayed_at.unwrap_or(created_at);
let anchor = DateTime::parse_from_rfc3339(&anchor_str)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or(now);
let age_days = (now - anchor).num_seconds() as f64 / crate::SECS_PER_DAY as f64;
let new_value = decayed(
current_confidence,
age_days,
half_life_for_namespace(&namespace),
);
let stamp = now.to_rfc3339();
let n = conn.execute(
"UPDATE memories
SET confidence = ?1,
confidence_source = 'decayed',
confidence_decayed_at = ?2
WHERE id = ?3",
params![new_value, stamp, id],
)?;
Ok(n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_age_returns_base() {
assert!((decayed(0.9, 0.0, 30.0) - 0.9).abs() < f64::EPSILON);
}
#[test]
fn half_life_halves_value() {
let v = decayed(1.0, 30.0, 30.0);
assert!((v - 0.5).abs() < 1e-6, "expected ~0.5 got {v}");
}
#[test]
fn very_old_collapses_toward_zero() {
let v = decayed(0.9, 365.0, 30.0);
assert!(v < 0.05, "expected near-zero got {v}");
}
#[test]
fn negative_age_treated_as_zero() {
assert!((decayed(0.7, -5.0, 30.0) - 0.7).abs() < f64::EPSILON);
}
#[test]
fn n15_half_life_for_unseeded_namespace_is_default() {
let hl = half_life_for_namespace("__n15_definitely_unseeded_namespace__");
assert!((hl - crate::confidence::DEFAULT_HALF_LIFE_DAYS).abs() < f64::EPSILON);
}
#[test]
fn zero_half_life_collapses_to_zero() {
let v = decayed(0.9, 1.0, 0.0);
assert!(v < 1e-6, "expected ~0 got {v}");
}
#[test]
fn output_clamped_to_unit_interval() {
let v = decayed(2.0, 0.0, 30.0);
assert!((v - 1.0).abs() < f64::EPSILON);
let v = decayed(-0.5, 0.0, 30.0);
assert!((v - 0.0).abs() < f64::EPSILON);
}
#[test]
fn monotonic_in_age() {
let a = decayed(1.0, 0.0, 30.0);
let b = decayed(1.0, 10.0, 30.0);
let c = decayed(1.0, 30.0, 30.0);
assert!(a > b && b > c, "should decay monotonically: {a} {b} {c}");
}
#[test]
fn decay_env_gating_default_off() {
unsafe { std::env::remove_var(ENV_DECAY) };
assert!(!decay_enabled());
unsafe { std::env::set_var(ENV_DECAY, "1") };
assert!(decay_enabled());
unsafe { std::env::remove_var(ENV_DECAY) };
}
}