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_DEFAULT, MEMORY_KIND_EPISODIC,
9    MEMORY_KIND_PROCEDURAL, MEMORY_KIND_PROP, MEMORY_LABEL, MEMORY_TOMBSTONE_PROPS,
10};
11use topodb::{Db, NodeId, Op, PropValue, ScopeSet};
12
13/// How many candidates a sweep reports by default.
14pub const LIFECYCLE_DEFAULT_LIMIT: usize = 20;
15/// Per-kind staleness half-lives (spec-fixed defaults, tunable per call):
16/// an episodic observation goes stale in weeks, a standing fact in months,
17/// a how-to in a year.
18pub const LIFECYCLE_HALF_LIFE_EPISODIC_DAYS: f64 = 14.0;
19pub const LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS: f64 = 120.0;
20pub const LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS: f64 = 365.0;
21
22const DAY_MS: f64 = 86_400_000.0;
23
24/// Tunables for one sweep. `Default` is the spec's policy.
25#[derive(Debug, Clone)]
26pub struct LifecycleParams {
27    /// Top-N candidates to report (by descending staleness).
28    pub limit: usize,
29    pub half_life_episodic_ms: i64,
30    pub half_life_semantic_ms: i64,
31    pub half_life_procedural_ms: i64,
32}
33
34impl Default for LifecycleParams {
35    fn default() -> Self {
36        Self {
37            limit: LIFECYCLE_DEFAULT_LIMIT,
38            half_life_episodic_ms: (LIFECYCLE_HALF_LIFE_EPISODIC_DAYS * DAY_MS) as i64,
39            half_life_semantic_ms: (LIFECYCLE_HALF_LIFE_SEMANTIC_DAYS * DAY_MS) as i64,
40            half_life_procedural_ms: (LIFECYCLE_HALF_LIFE_PROCEDURAL_DAYS * DAY_MS) as i64,
41        }
42    }
43}
44
45/// One decay candidate with its full evidence — everything the judge needs
46/// to decide keep|forget without another lookup.
47#[derive(Debug, Clone, serde::Serialize)]
48pub struct LifecycleCandidate {
49    pub id: String,
50    pub content: String,
51    /// The EFFECTIVE kind: the stored value when valid, else `semantic`
52    /// (absent means semantic; an out-of-vocabulary value degrades to the
53    /// default rather than erroring a read).
54    pub kind: String,
55    /// The node id's ULID mint timestamp (ms).
56    pub created_at: i64,
57    /// Raw counter value; `0` = never accessed (the age computation falls
58    /// back to `created_at`, this field stays raw evidence).
59    pub last_accessed_at: i64,
60    pub access_count: u64,
61    pub staleness: f64,
62}
63
64/// The spec's staleness score: `(age / half_life) / ln(e + access_count)`.
65/// Negative ages (a node minted after `now` — clock skew or a backdated
66/// sweep) clamp to 0. Pure and exported so tests and future policy layers
67/// can pin the formula itself.
68pub fn staleness(age_ms: i64, half_life_ms: i64, access_count: u64) -> f64 {
69    let age = age_ms.max(0) as f64;
70    (age / half_life_ms as f64) / (std::f64::consts::E + access_count as f64).ln()
71}
72
73/// The Phase C sweep: rank live memories in `scopes` by staleness and
74/// return the top `params.limit` with full evidence. Read-only and
75/// unbumped end to end. `now_ms` is injectable for determinism.
76pub fn lifecycle_candidates(
77    db: &Db,
78    scopes: &ScopeSet,
79    params: &LifecycleParams,
80    now_ms: i64,
81) -> Result<Vec<LifecycleCandidate>, ComposeError> {
82    if params.limit == 0 {
83        return Err(ComposeError::Invalid("limit must be at least 1".into()));
84    }
85    for (kind, hl) in [
86        (MEMORY_KIND_EPISODIC, params.half_life_episodic_ms),
87        (crate::MEMORY_KIND_SEMANTIC, params.half_life_semantic_ms),
88        (MEMORY_KIND_PROCEDURAL, params.half_life_procedural_ms),
89    ] {
90        if hl <= 0 {
91            return Err(ComposeError::Invalid(format!(
92                "half-lives must be positive, got {hl}ms for {kind}"
93            )));
94        }
95    }
96
97    let mut out: Vec<LifecycleCandidate> = Vec::new();
98    for node in db.nodes_by_label_unbumped(scopes, MEMORY_LABEL) {
99        // Same liveness motion as every hygiene scan: a tombstone key's
100        // presence retires the memory from proposal.
101        if MEMORY_TOMBSTONE_PROPS
102            .iter()
103            .any(|p| node.props.contains_key(*p))
104        {
105            continue;
106        }
107        let content = match node.props.get(MEMORY_CONTENT_PROP) {
108            Some(PropValue::Str(c)) => c.clone(),
109            _ => continue,
110        };
111        let kind = match node.props.get(MEMORY_KIND_PROP) {
112            Some(PropValue::Str(k)) if MEMORY_KINDS.contains(&k.as_str()) => k.clone(),
113            _ => MEMORY_KIND_DEFAULT.to_string(),
114        };
115        let half_life_ms = if kind == MEMORY_KIND_EPISODIC {
116            params.half_life_episodic_ms
117        } else if kind == MEMORY_KIND_PROCEDURAL {
118            params.half_life_procedural_ms
119        } else {
120            params.half_life_semantic_ms
121        };
122        let stats = db.access_stats(scopes, node.id)?.unwrap_or_default();
123        let created_at = node.id.timestamp_ms() as i64;
124        let age_ms = now_ms - stats.last_accessed_at.max(created_at);
125        out.push(LifecycleCandidate {
126            id: node.id.to_string(),
127            content,
128            kind,
129            created_at,
130            last_accessed_at: stats.last_accessed_at,
131            access_count: stats.access_count,
132            staleness: staleness(age_ms, half_life_ms, stats.access_count),
133        });
134    }
135
136    // Descending staleness; ties by ascending id — fully deterministic
137    // under an injected now_ms. staleness is never NaN (half-lives are
138    // validated positive, ages clamp at 0), so partial_cmp cannot fail.
139    out.sort_by(|a, b| {
140        b.staleness
141            .partial_cmp(&a.staleness)
142            .unwrap_or(std::cmp::Ordering::Equal)
143            .then_with(|| a.id.cmp(&b.id))
144    });
145    out.truncate(params.limit);
146    Ok(out)
147}
148
149/// Phase E: plan the destructive purge. Selects every Memory node in
150/// `scopes` whose ANY tombstone prop holds an `Int` strictly older than
151/// `tombstoned_before_ms` and returns one `Op::RemoveNode` per hit plus
152/// the ascending-sorted id list (same order). The caller decides whether
153/// to submit — the CLI's dry-run prints this plan without ever writing.
154/// Non-`Int` tombstone values are not marks (the engine's liveness rule);
155/// live nodes are never selected. Purge is deliberately CLI-only and
156/// never part of the lifecycle graph: reclamation is an operator action.
157pub fn plan_purge(
158    db: &Db,
159    scopes: &ScopeSet,
160    tombstoned_before_ms: i64,
161) -> Result<(Vec<Op>, Vec<String>), ComposeError> {
162    if tombstoned_before_ms <= 0 {
163        return Err(ComposeError::Invalid(
164            "tombstoned-before must be a positive unix-ms timestamp".into(),
165        ));
166    }
167    let mut doomed: Vec<NodeId> = Vec::new();
168    for node in db.nodes_by_label_unbumped(scopes, MEMORY_LABEL) {
169        let qualifies = MEMORY_TOMBSTONE_PROPS.iter().any(
170            |p| matches!(node.props.get(*p), Some(PropValue::Int(t)) if *t < tombstoned_before_ms),
171        );
172        if qualifies {
173            doomed.push(node.id);
174        }
175    }
176    doomed.sort();
177    let ids = doomed.iter().map(|id| id.to_string()).collect();
178    let ops = doomed.into_iter().map(|id| Op::RemoveNode { id }).collect();
179    Ok((ops, ids))
180}