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}