Skip to main content

ai_memory/confidence/
decay.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 Form 5 — freshness-decay updater.
5//!
6//! The [`decayed`] function returns an exponentially decayed copy of
7//! the input confidence value. Recall paths can call it on touch to
8//! soft-floor stale rows; the actual write back into
9//! `memories.confidence` happens only when
10//! `AI_MEMORY_CONFIDENCE_DECAY=1` or the namespace policy carries
11//! `confidence_decay_half_life_days`.
12//!
13//! The recall-time substrate touch lives in [`apply_decay_touch`]:
14//! when `AI_MEMORY_CONFIDENCE_DECAY=1` and a memory is recalled,
15//! `crate::store::sqlite::touch_after_recall` calls this helper to
16//! UPDATE the row in place, stamp `confidence_decayed_at`, overwrite
17//! `confidence` with the decayed value, and flip `confidence_source`
18//! to [`crate::models::ConfidenceSource::Decayed`].
19//!
20//! Audit-honest contract: this module is pure (the math) plus one
21//! tightly-scoped substrate writer ([`apply_decay_touch`]) that lives
22//! here — not in `src/storage/mod.rs` — so the Cluster F recall SQL
23//! stays untouched.
24
25use rusqlite::{Connection, params};
26
27/// Environment-variable opt-in for the recall-time decay updater.
28pub const ENV_DECAY: &str = "AI_MEMORY_CONFIDENCE_DECAY";
29
30/// Returns true when [`ENV_DECAY`] is set to `"1"`.
31#[must_use]
32pub fn decay_enabled() -> bool {
33    std::env::var(ENV_DECAY).is_ok_and(|v| v == "1")
34}
35
36/// n15 — process-global per-namespace confidence-decay half-life
37/// overrides (days), seeded once at boot from
38/// `[curator.confidence_decay_half_life_days]`
39/// (`AppConfig::confidence_decay_half_life_overrides`). `apply_decay_touch`
40/// is a `&Connection`-bound free fn with no `AppConfig` in scope, so the
41/// override is resolved through this global rather than threaded through
42/// every recall-touch caller — the same boot-seeded-global pattern as
43/// `crate::reranker::set_rerank_max_seq` and the quota defaults.
44static NAMESPACE_HALF_LIFE: std::sync::OnceLock<std::collections::HashMap<String, f64>> =
45    std::sync::OnceLock::new();
46
47/// n15 — seed the per-namespace half-life overrides once at boot.
48/// First-writer-wins (`OnceLock`); a re-seed is silently ignored.
49pub fn set_namespace_half_life_overrides(overrides: std::collections::HashMap<String, f64>) {
50    let _ = NAMESPACE_HALF_LIFE.set(overrides);
51}
52
53/// n15 — borrow the boot-seeded per-namespace half-life overrides when
54/// any were configured (and non-empty). The sqlite per-id decay path
55/// uses [`half_life_for_namespace`]; the postgres bulk-decay path uses
56/// this to build a per-namespace `CASE` so both backends apply the same
57/// override. `None` (the common case) → both backends use the compiled
58/// default and the simple single-half-life path.
59#[must_use]
60pub fn namespace_half_life_overrides() -> Option<&'static std::collections::HashMap<String, f64>> {
61    NAMESPACE_HALF_LIFE.get().filter(|m| !m.is_empty())
62}
63
64/// n15 — resolve the half-life (days) for `namespace`: the boot-seeded
65/// override when present + finite + `> 0`, else
66/// [`crate::confidence::DEFAULT_HALF_LIFE_DAYS`].
67#[must_use]
68pub fn half_life_for_namespace(namespace: &str) -> f64 {
69    NAMESPACE_HALF_LIFE
70        .get()
71        .and_then(|m| m.get(namespace))
72        .copied()
73        .filter(|v| v.is_finite() && *v > 0.0)
74        .unwrap_or(crate::confidence::DEFAULT_HALF_LIFE_DAYS)
75}
76
77/// Compute a decayed confidence value.
78///
79/// `base` is the stored value; `age_days` is the time elapsed since
80/// the value was last written (typically `now - max(created_at,
81/// confidence_decayed_at)`); `half_life_days` is the namespace-policy
82/// override or [`crate::confidence::DEFAULT_HALF_LIFE_DAYS`].
83///
84/// Formula: `base * 2^(-age / half_life)`, i.e.
85/// `base * exp(-age * ln(2) / half_life)`. Honours the standard
86/// half-life convention: at `age = half_life`, the value collapses to
87/// `0.5 * base`. Clamped to `[0.0, 1.0]`. Negative `age_days` is
88/// treated as `0.0` (no future-dated decay). `half_life_days <= 0`
89/// is treated as `f64::EPSILON` so the divisor never goes to zero
90/// (the value collapses to 0 in that case, which is the documented
91/// degenerate-input contract).
92#[must_use]
93pub fn decayed(base: f64, age_days: f64, half_life_days: f64) -> f64 {
94    let age = age_days.max(0.0);
95    let half_life = half_life_days.max(f64::EPSILON);
96    let factor = (-age * std::f64::consts::LN_2 / half_life).exp();
97    (base * factor).clamp(0.0, 1.0)
98}
99
100/// Substrate-side decay touch fired from `touch_after_recall` when
101/// `AI_MEMORY_CONFIDENCE_DECAY=1`. Reads the row's current
102/// `confidence`, `created_at`, `confidence_decayed_at`, and
103/// `namespace`, computes the decayed value via [`decayed`] using the
104/// per-namespace half-life resolved by [`half_life_for_namespace`]
105/// (n15 — `[curator.confidence_decay_half_life_days]` override >
106/// [`crate::confidence::DEFAULT_HALF_LIFE_DAYS`]), and writes back the
107/// new value, the `'decayed'` source marker, and a fresh
108/// `confidence_decayed_at` timestamp.
109///
110/// Idempotent — re-running on a row that has already been decayed
111/// uses the most recent `confidence_decayed_at` as the age anchor, so
112/// repeated touches converge rather than collapsing the value to zero.
113/// Returns `Ok(true)` when a row was updated, `Ok(false)` when no row
114/// matched the id (silently swallowed by the caller).
115///
116/// # Errors
117///
118/// Returns the underlying `rusqlite` error on SQL failure.
119pub fn apply_decay_touch(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
120    use chrono::{DateTime, Utc};
121    // Read the row's age anchor + current confidence in one shot. The
122    // anchor is `confidence_decayed_at` when present (subsequent
123    // decays compute from the last decay timestamp, not creation),
124    // falling back to `created_at` for first-touch rows.
125    // n15 — read `namespace` too so the per-namespace half-life override
126    // (boot-seeded from `[curator.confidence_decay_half_life_days]`) can
127    // be resolved per row instead of always using the compiled default.
128    let row: Option<(f64, String, Option<String>, String)> = conn
129        .query_row(
130            "SELECT confidence, created_at, confidence_decayed_at, namespace
131             FROM memories WHERE id = ?1",
132            params![id],
133            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
134        )
135        .ok();
136    let Some((current_confidence, created_at, decayed_at, namespace)) = row else {
137        return Ok(false);
138    };
139
140    let now = Utc::now();
141    let anchor_str = decayed_at.unwrap_or(created_at);
142    let anchor = DateTime::parse_from_rfc3339(&anchor_str)
143        .map(|dt| dt.with_timezone(&Utc))
144        .unwrap_or(now);
145    let age_days = (now - anchor).num_seconds() as f64 / crate::SECS_PER_DAY as f64;
146    // n15 — per-namespace half-life override > compiled default.
147    let new_value = decayed(
148        current_confidence,
149        age_days,
150        half_life_for_namespace(&namespace),
151    );
152    let stamp = now.to_rfc3339();
153    // v0.7.0 #1036 (Agent-3 #7) — intentionally non-version-bumping
154    // write. This is a periodic system sweep updating confidence
155    // calibration metadata, NOT a user-initiated content edit. The
156    // optimistic-concurrency contract (Gap-1 #884) protects against
157    // concurrent USER edits via `memories.version`. Bumping version
158    // here would cause spurious VersionConflict on the next user
159    // `update_with_expected_version` call (the user's stale `version`
160    // would diverge from the row's decay-bumped value), breaking
161    // the user-facing concurrency story without protecting any
162    // real cross-edit race (the decay sweep is monotonic + idempotent
163    // — re-running on the same row converges to the same value
164    // modulo the slow time decay).
165    //
166    // Pinned by `tests/non_version_bumping_sites_1036.rs` —
167    // a fresh `version` MUST equal the pre-decay `version` post-call.
168    let n = conn.execute(
169        "UPDATE memories
170         SET confidence = ?1,
171             confidence_source = 'decayed',
172             confidence_decayed_at = ?2
173         WHERE id = ?3",
174        params![new_value, stamp, id],
175    )?;
176    Ok(n > 0)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn no_age_returns_base() {
185        assert!((decayed(0.9, 0.0, 30.0) - 0.9).abs() < f64::EPSILON);
186    }
187
188    #[test]
189    fn half_life_halves_value() {
190        // base=1.0, age=half_life ⇒ ~0.5
191        let v = decayed(1.0, 30.0, 30.0);
192        assert!((v - 0.5).abs() < 1e-6, "expected ~0.5 got {v}");
193    }
194
195    #[test]
196    fn very_old_collapses_toward_zero() {
197        let v = decayed(0.9, 365.0, 30.0);
198        assert!(v < 0.05, "expected near-zero got {v}");
199    }
200
201    #[test]
202    fn negative_age_treated_as_zero() {
203        assert!((decayed(0.7, -5.0, 30.0) - 0.7).abs() < f64::EPSILON);
204    }
205
206    #[test]
207    fn n15_half_life_for_unseeded_namespace_is_default() {
208        // n15 — a namespace with no seeded override resolves to the
209        // compiled default. Uses a sentinel namespace no other test
210        // seeds, so the assertion is order-independent against the
211        // process-global OnceLock (the seed→read value logic is covered
212        // deterministically by the AppConfig resolver tests).
213        let hl = half_life_for_namespace("__n15_definitely_unseeded_namespace__");
214        assert!((hl - crate::confidence::DEFAULT_HALF_LIFE_DAYS).abs() < f64::EPSILON);
215    }
216
217    #[test]
218    fn zero_half_life_collapses_to_zero() {
219        // Degenerate input: half_life=0 ⇒ value collapses to 0
220        // immediately (no future-dated decay; this is the contract).
221        let v = decayed(0.9, 1.0, 0.0);
222        assert!(v < 1e-6, "expected ~0 got {v}");
223    }
224
225    #[test]
226    fn output_clamped_to_unit_interval() {
227        // base > 1.0 is a bug elsewhere but the function must not
228        // propagate it.
229        let v = decayed(2.0, 0.0, 30.0);
230        assert!((v - 1.0).abs() < f64::EPSILON);
231        let v = decayed(-0.5, 0.0, 30.0);
232        assert!((v - 0.0).abs() < f64::EPSILON);
233    }
234
235    #[test]
236    fn monotonic_in_age() {
237        let a = decayed(1.0, 0.0, 30.0);
238        let b = decayed(1.0, 10.0, 30.0);
239        let c = decayed(1.0, 30.0, 30.0);
240        assert!(a > b && b > c, "should decay monotonically: {a} {b} {c}");
241    }
242
243    #[test]
244    fn decay_env_gating_default_off() {
245        unsafe { std::env::remove_var(ENV_DECAY) };
246        assert!(!decay_enabled());
247        unsafe { std::env::set_var(ENV_DECAY, "1") };
248        assert!(decay_enabled());
249        unsafe { std::env::remove_var(ENV_DECAY) };
250    }
251}