Skip to main content

topodb_json/
lifecycle.rs

1//! Phase C of the memory lifecycle: the decay-candidate sweep. Deterministic,
2//! unbumped, read-only — it surfaces evidence and NEVER stamps anything (the
3//! judge acts through `forget`/`consolidate_memories`, spec decision 5). All
4//! policy lives here: the engine only supplies the non-bumping scan
5//! primitives (`nodes_by_label_unbumped`, `access_stats`).
6
7use crate::{
8    ComposeError, MEMORY_CONTENT_PROP, MEMORY_KINDS, MEMORY_KIND_DECISION, MEMORY_KIND_DEFAULT,
9    MEMORY_KIND_EPISODIC, MEMORY_KIND_PROCEDURAL, MEMORY_KIND_PROP, MEMORY_LABEL,
10    MEMORY_TOMBSTONE_PROPS,
11};
12use topodb::{Db, NodeId, Op, PropValue, ScopeSet};
13
14/// How many candidates a sweep reports by default.
15pub const LIFECYCLE_DEFAULT_LIMIT: usize = 20;
16/// Per-kind staleness half-lives (spec-fixed defaults, tunable per call):
17/// an episodic observation goes stale in weeks, a standing fact in months,
18/// a how-to in a year.
19pub const LIFECYCLE_HALF_LIFE_EPISODIC_DAYS: f64 = 14.0;
20pub const LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS: f64 = 120.0;
21pub const LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS: f64 = 365.0;
22/// A deliberate tie to the semantic constant: decisions age like standing
23/// facts. Change independently if dogfood shows otherwise.
24pub const LIFECYCLE_HALF_LIFE_DECISION_DAYS: f64 = LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS;
25
26const DAY_MS: f64 = 86_400_000.0;
27
28/// Tunables for one sweep. `Default` is the spec's policy.
29#[derive(Debug, Clone)]
30pub struct LifecycleParams {
31    /// Top-N candidates to report (by descending staleness).
32    pub limit: usize,
33    pub half_life_episodic_ms: i64,
34    pub half_life_semantic_ms: i64,
35    pub half_life_procedural_ms: i64,
36    pub half_life_decision_ms: i64,
37}
38
39impl Default for LifecycleParams {
40    fn default() -> Self {
41        Self {
42            limit: LIFECYCLE_DEFAULT_LIMIT,
43            half_life_episodic_ms: (LIFECYCLE_HALF_LIFE_EPISODIC_DAYS * DAY_MS) as i64,
44            half_life_semantic_ms: (LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS * DAY_MS) as i64,
45            half_life_procedural_ms: (LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS * DAY_MS) as i64,
46            half_life_decision_ms: (LIFECYCLE_HALF_LIFE_DECISION_DAYS * DAY_MS) as i64,
47        }
48    }
49}
50
51/// One decay candidate with its full evidence — everything the judge needs
52/// to decide keep|forget without another lookup.
53#[derive(Debug, Clone, serde::Serialize)]
54pub struct LifecycleCandidate {
55    pub id: String,
56    pub content: String,
57    /// The EFFECTIVE kind: the stored value when valid, else `semantic`
58    /// (absent means semantic; an out-of-vocabulary value degrades to the
59    /// default rather than erroring a read).
60    pub kind: String,
61    /// The node id's ULID mint timestamp (ms).
62    pub created_at: i64,
63    /// Raw counter value; `0` = never accessed (the age computation falls
64    /// back to `created_at`, this field stays raw evidence).
65    pub last_accessed_at: i64,
66    pub access_count: u64,
67    pub staleness: f64,
68}
69
70/// The spec's staleness score: `(age / half_life) / ln(e + access_count)`.
71/// Negative ages (a node minted after `now` — clock skew or a backdated
72/// sweep) clamp to 0. Pure and exported so tests and future policy layers
73/// can pin the formula itself.
74pub fn staleness(age_ms: i64, half_life_ms: i64, access_count: u64) -> f64 {
75    let age = age_ms.max(0) as f64;
76    (age / half_life_ms as f64) / (std::f64::consts::E + access_count as f64).ln()
77}
78
79/// The Phase C sweep: rank live memories in `scopes` by staleness and
80/// return the top `params.limit` with full evidence. Read-only and
81/// unbumped end to end. `now_ms` is injectable for determinism.
82pub fn lifecycle_candidates(
83    db: &Db,
84    scopes: &ScopeSet,
85    params: &LifecycleParams,
86    now_ms: i64,
87) -> Result<Vec<LifecycleCandidate>, ComposeError> {
88    if params.limit == 0 {
89        return Err(ComposeError::Invalid("limit must be at least 1".into()));
90    }
91    for (kind, hl) in [
92        (MEMORY_KIND_EPISODIC, params.half_life_episodic_ms),
93        (crate::MEMORY_KIND_SEMANTIC, params.half_life_semantic_ms),
94        (MEMORY_KIND_PROCEDURAL, params.half_life_procedural_ms),
95        (MEMORY_KIND_DECISION, params.half_life_decision_ms),
96    ] {
97        if hl <= 0 {
98            return Err(ComposeError::Invalid(format!(
99                "half-lives must be positive, got {hl}ms for {kind}"
100            )));
101        }
102    }
103
104    let mut out: Vec<LifecycleCandidate> = Vec::new();
105    for node in db.nodes_by_label_unbumped(scopes, MEMORY_LABEL) {
106        // Same liveness motion as every hygiene scan: a tombstone key's
107        // presence retires the memory from proposal.
108        if MEMORY_TOMBSTONE_PROPS
109            .iter()
110            .any(|p| node.props.contains_key(*p))
111        {
112            continue;
113        }
114        let content = match node.props.get(MEMORY_CONTENT_PROP) {
115            Some(PropValue::Str(c)) => c.clone(),
116            _ => continue,
117        };
118        let kind = match node.props.get(MEMORY_KIND_PROP) {
119            Some(PropValue::Str(k)) if MEMORY_KINDS.contains(&k.as_str()) => k.clone(),
120            _ => MEMORY_KIND_DEFAULT.to_string(),
121        };
122        let half_life_ms = match kind.as_str() {
123            MEMORY_KIND_EPISODIC => params.half_life_episodic_ms,
124            MEMORY_KIND_PROCEDURAL => params.half_life_procedural_ms,
125            // Named explicitly, not left to the fall-through: the shared
126            // semantic tunable IS decision's half-life (deliberate 120d tie).
127            MEMORY_KIND_DECISION => params.half_life_decision_ms,
128            _ => params.half_life_semantic_ms,
129        };
130        let stats = db.access_stats(scopes, node.id)?.unwrap_or_default();
131        let created_at = node.id.timestamp_ms() as i64;
132        let age_ms = now_ms - stats.last_accessed_at.max(created_at);
133        out.push(LifecycleCandidate {
134            id: node.id.to_string(),
135            content,
136            kind,
137            created_at,
138            last_accessed_at: stats.last_accessed_at,
139            access_count: stats.access_count,
140            staleness: staleness(age_ms, half_life_ms, stats.access_count),
141        });
142    }
143
144    // Descending staleness; ties by ascending id — fully deterministic
145    // under an injected now_ms. staleness is never NaN (half-lives are
146    // validated positive, ages clamp at 0), so partial_cmp cannot fail.
147    out.sort_by(|a, b| {
148        b.staleness
149            .partial_cmp(&a.staleness)
150            .unwrap_or(std::cmp::Ordering::Equal)
151            .then_with(|| a.id.cmp(&b.id))
152    });
153    out.truncate(params.limit);
154    Ok(out)
155}
156
157/// Phase E: plan the destructive purge. Selects every Memory node in
158/// `scopes` whose ANY tombstone prop holds an `Int` strictly older than
159/// `tombstoned_before_ms` and returns one `Op::RemoveNode` per hit plus
160/// the ascending-sorted id list (same order). The caller decides whether
161/// to submit — the CLI's dry-run prints this plan without ever writing.
162/// Non-`Int` tombstone values are not marks (the engine's liveness rule);
163/// live nodes are never selected. Purge is deliberately CLI-only and
164/// never part of the lifecycle graph: reclamation is an operator action.
165pub fn plan_purge(
166    db: &Db,
167    scopes: &ScopeSet,
168    tombstoned_before_ms: i64,
169) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
170    if tombstoned_before_ms <= 0 {
171        return Err(ComposeError::Invalid(
172            "tombstoned-before must be a positive unix-ms timestamp".into(),
173        ));
174    }
175    let mut doomed: Vec<NodeId> = Vec::new();
176    for node in db.nodes_by_label_unbumped(scopes, MEMORY_LABEL) {
177        let qualifies = MEMORY_TOMBSTONE_PROPS.iter().any(
178            |p| matches!(node.props.get(*p), Some(PropValue::Int(t)) if *t < tombstoned_before_ms),
179        );
180        if qualifies {
181            doomed.push(node.id);
182        }
183    }
184    doomed.sort();
185    let ids = doomed.iter().map(|id| id.to_string()).collect();
186    let ops = doomed.into_iter().map(|id| Op::RemoveNode { id }).collect();
187    Ok((ops, ids))
188}
189
190/// The kind→half-life map for SEARCH RANKING, built from the same
191/// constants the lifecycle decay sweep uses so the two can never drift.
192/// Semantic is the map's default bucket: absent kind reads as semantic
193/// everywhere in the system, and non-Memory nodes (entities) deliberately
194/// decay on the semantic curve too.
195pub fn memory_kind_half_life() -> topodb::PropHalfLife {
196    topodb::PropHalfLife {
197        prop: MEMORY_KIND_PROP.to_string(),
198        per_value: vec![
199            (
200                MEMORY_KIND_EPISODIC.to_string(),
201                (LIFECYCLE_HALF_LIFE_EPISODIC_DAYS * DAY_MS) as i64,
202            ),
203            (
204                MEMORY_KIND_PROCEDURAL.to_string(),
205                (LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS * DAY_MS) as i64,
206            ),
207            // Numerically the default bucket today, but named explicitly:
208            // the decision constant may diverge from semantic later, and an
209            // explicit entry is what the mirror test can pin.
210            (
211                MEMORY_KIND_DECISION.to_string(),
212                (LIFECYCLE_HALF_LIFE_DECISION_DAYS * DAY_MS) as i64,
213            ),
214        ],
215        default_ms: (LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS * DAY_MS) as i64,
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    /// Ranking and the decay sweep must share one clock: the search half-life
224    /// map is BUILT from the lifecycle constants, and semantic is deliberately
225    /// the default bucket (absent kind == semantic everywhere else in the
226    /// system, so it must not appear as an explicit entry that could drift).
227    /// Every OTHER kind in the closed vocabulary gets an explicit entry —
228    /// including `decision`, whose constant ties to semantic today but must
229    /// stay independently pinned so the tie can be broken deliberately.
230    /// The lifecycle sweep's decision half-life (LifecycleParams) is also
231    /// pinned to the same constant, so editing LIFECYCLE_HALF_LIFE_DECISION_DAYS
232    /// automatically updates both search ranking and decay candidates.
233    #[test]
234    fn memory_kind_half_life_mirrors_lifecycle_constants() {
235        let map = memory_kind_half_life();
236        assert_eq!(map.prop, MEMORY_KIND_PROP);
237        assert_eq!(
238            map.default_ms,
239            (LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS * DAY_MS) as i64
240        );
241        assert_eq!(
242            map.per_value,
243            vec![
244                (
245                    MEMORY_KIND_EPISODIC.to_string(),
246                    (LIFECYCLE_HALF_LIFE_EPISODIC_DAYS * DAY_MS) as i64
247                ),
248                (
249                    MEMORY_KIND_PROCEDURAL.to_string(),
250                    (LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS * DAY_MS) as i64
251                ),
252                (
253                    MEMORY_KIND_DECISION.to_string(),
254                    (LIFECYCLE_HALF_LIFE_DECISION_DAYS * DAY_MS) as i64
255                ),
256            ]
257        );
258        // The map's explicit entries plus the default bucket must cover the
259        // closed vocabulary exactly — a fifth kind without a wiring decision
260        // here is the drift this test exists to catch.
261        let explicit: Vec<&str> = map.per_value.iter().map(|(k, _)| k.as_str()).collect();
262        for kind in MEMORY_KINDS {
263            assert!(
264                explicit.contains(&kind) || kind == crate::MEMORY_KIND_DEFAULT,
265                "kind {kind:?} has no half-life wiring"
266            );
267        }
268        // The lifecycle sweep's decision half-life must also mirror the constant,
269        // so the tie cannot drift between the two independent tables.
270        let params = LifecycleParams::default();
271        assert_eq!(
272            params.half_life_decision_ms,
273            (LIFECYCLE_HALF_LIFE_DECISION_DAYS * DAY_MS) as i64,
274            "lifecycle sweep's decision half-life must equal LIFECYCLE_HALF_LIFE_DECISION_DAYS constant"
275        );
276        // And it must match what memory_kind_half_life() declares (the same constant).
277        let map_decision_ms = map
278            .per_value
279            .iter()
280            .find(|(k, _)| k == MEMORY_KIND_DECISION)
281            .map(|(_, ms)| *ms)
282            .expect("decision must have an explicit entry");
283        assert_eq!(
284            params.half_life_decision_ms, map_decision_ms,
285            "lifecycle sweep and search ranking must use the same decision half-life"
286        );
287    }
288
289    /// The 120d tie is deliberate and named: decision's constant equals
290    /// semantic's, so a decision memory decays on the standing-fact curve
291    /// until dogfood says otherwise.
292    #[test]
293    fn decision_half_life_ties_to_semantic() {
294        assert_eq!(
295            LIFECYCLE_HALF_LIFE_DECISION_DAYS,
296            LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS
297        );
298    }
299}