Skip to main content

cel_memory/
importance.rs

1//! Importance scoring for memory chunks.
2//!
3//! Per `cellar-memory-manager.md` §10.2: importance is a real in `[0,1]`
4//! assigned at write time. It drives eviction priority — chunks with
5//! lower importance are evicted first when storage caps or aging
6//! sweeps run.
7//!
8//! The scorer is a pure function over the new chunk's metadata
9//! (kind, source, content length, explicit caller hint). It produces
10//! a default score when [`NewMemoryChunk::importance`] is `None`; if
11//! the caller supplies a value, that value is honored verbatim (after
12//! clamping). The provider calls [`score`] at write time and stores the
13//! result in `memory_chunks.importance`.
14//!
15//! Heuristic weights (signals that nudge importance up or down):
16//!
17//! | Signal | Δ |
18//! |---|---:|
19//! | `kind = Correction` | +0.4 |
20//! | `kind = Fire` and metadata says require_confirmation | +0.2 |
21//! | `kind = Action` and metadata says decision = denied | +0.2 |
22//! | `kind = JobSummary` | +0.2 |
23//! | metadata `"user_ack": true` | +0.3 |
24//! | metadata `"pinned_entity": true` | +0.1 |
25//! | `kind = Observation` and metadata `"stability": "transient"` | −0.2 |
26//! | `kind = Context` and metadata `"duration_ms"` small (<2000) | −0.1 |
27//!
28//! Baseline: 0.5.
29//!
30//! Floor: 0.1 for `Chat` and `Action` chunks regardless of score —
31//! the user's own words and the agent's own actions are never evicted
32//! purely because their importance number is low.
33//!
34//! Use-cite bumps (+0.05 per cite, capped at +0.2) are applied via
35//! [`crate::MemoryProvider::record_access`] later, not at write time.
36
37use crate::chunk::{ChunkKind, NewMemoryChunk};
38
39/// Compute the initial importance score for a new chunk.
40///
41/// If the caller passed an explicit `importance` value on
42/// [`NewMemoryChunk`], that value is returned (clamped to `[0,1]`).
43/// Otherwise the scorer applies the heuristic above starting from a
44/// baseline of `0.5` and returns the clamped result.
45//
46// `collapsible_match` would convert each `Kind { if ... }` arm into a
47// match guard. The current shape is easier to read when each kind needs
48// to inspect metadata before deciding whether to bump — guards spread
49// the per-kind logic across two arms (matched-with-guard vs fallthrough),
50// which is harder to follow when adding new signals.
51#[allow(clippy::collapsible_match)]
52pub fn score(chunk: &NewMemoryChunk) -> f32 {
53    // Caller-supplied importance takes priority (deliberate override
54    // path — agents and external MCP clients can hint).
55    if let Some(explicit) = chunk.importance {
56        return clamp(explicit);
57    }
58
59    let mut score: f32 = 0.5;
60
61    match chunk.kind {
62        ChunkKind::Correction => score += 0.4,
63        ChunkKind::JobSummary => score += 0.2,
64        ChunkKind::Fire => {
65            if metadata_str(chunk, "action_type")
66                .map(|s| s == "RequireConfirmation" || s == "Veto")
67                .unwrap_or(false)
68            {
69                score += 0.2;
70            }
71        }
72        ChunkKind::Action => {
73            if metadata_str(chunk, "decision")
74                .map(|s| s == "denied" || s == "vetoed")
75                .unwrap_or(false)
76            {
77                score += 0.2;
78            }
79        }
80        ChunkKind::Observation => {
81            if metadata_str(chunk, "stability")
82                .map(|s| s == "transient")
83                .unwrap_or(false)
84            {
85                score -= 0.2;
86            }
87        }
88        ChunkKind::Context => {
89            if metadata_i64(chunk, "duration_ms")
90                .map(|ms| ms < 2000)
91                .unwrap_or(false)
92            {
93                score -= 0.1;
94            }
95        }
96        _ => {}
97    }
98
99    if metadata_bool(chunk, "user_ack") {
100        score += 0.3;
101    }
102    if metadata_bool(chunk, "pinned_entity") {
103        score += 0.1;
104    }
105
106    let clamped = clamp(score);
107
108    // Floor: chat and action chunks never go below 0.1 — the user's
109    // own words and the agent's own actions are too important to evict
110    // purely on score.
111    match chunk.kind {
112        ChunkKind::Chat | ChunkKind::Action => clamped.max(0.1),
113        _ => clamped,
114    }
115}
116
117fn clamp(v: f32) -> f32 {
118    v.clamp(0.0, 1.0)
119}
120
121fn metadata_str<'a>(chunk: &'a NewMemoryChunk, key: &str) -> Option<&'a str> {
122    chunk.metadata.get(key).and_then(|v| v.as_str())
123}
124
125fn metadata_bool(chunk: &NewMemoryChunk, key: &str) -> bool {
126    chunk
127        .metadata
128        .get(key)
129        .and_then(|v| v.as_bool())
130        .unwrap_or(false)
131}
132
133fn metadata_i64(chunk: &NewMemoryChunk, key: &str) -> Option<i64> {
134    chunk.metadata.get(key).and_then(|v| v.as_i64())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::chunk::{ChunkKind, ChunkSource};
141    use serde_json::json;
142
143    fn nc(kind: ChunkKind) -> NewMemoryChunk {
144        NewMemoryChunk {
145            kind,
146            source: ChunkSource::Embedded,
147            session_id: None,
148            project_root: None,
149            caller_id: "test".into(),
150            content: "x".into(),
151            metadata: json!({}),
152            importance: None,
153            shareable: false,
154            pinned: false,
155        }
156    }
157
158    #[test]
159    fn baseline_is_half() {
160        let s = score(&nc(ChunkKind::Observation));
161        assert_eq!(s, 0.5);
162    }
163
164    #[test]
165    fn correction_bumps_to_high() {
166        let s = score(&nc(ChunkKind::Correction));
167        assert!((s - 0.9).abs() < f32::EPSILON);
168    }
169
170    #[test]
171    fn job_summary_bumps_above_baseline() {
172        let s = score(&nc(ChunkKind::JobSummary));
173        assert!((s - 0.7).abs() < f32::EPSILON);
174    }
175
176    #[test]
177    fn fire_with_require_confirmation_bumps() {
178        let mut c = nc(ChunkKind::Fire);
179        c.metadata = json!({"action_type": "RequireConfirmation"});
180        assert!((score(&c) - 0.7).abs() < f32::EPSILON);
181    }
182
183    #[test]
184    fn action_denied_bumps() {
185        let mut c = nc(ChunkKind::Action);
186        c.metadata = json!({"decision": "denied"});
187        assert!((score(&c) - 0.7).abs() < f32::EPSILON);
188    }
189
190    #[test]
191    fn transient_observation_reduced() {
192        let mut c = nc(ChunkKind::Observation);
193        c.metadata = json!({"stability": "transient"});
194        assert!((score(&c) - 0.3).abs() < f32::EPSILON);
195    }
196
197    #[test]
198    fn user_ack_and_pinned_entity_stack() {
199        let mut c = nc(ChunkKind::Chat);
200        c.metadata = json!({"user_ack": true, "pinned_entity": true});
201        // 0.5 + 0.3 + 0.1 = 0.9
202        assert!((score(&c) - 0.9).abs() < f32::EPSILON);
203    }
204
205    #[test]
206    fn chat_floor_protects_against_zero() {
207        let mut c = nc(ChunkKind::Chat);
208        // Drag well below the floor with metadata signals
209        c.metadata = json!({"stability": "transient"});
210        // Stability only applies to Observation kind — score should be 0.5.
211        // Force the issue by passing explicit importance.
212        c.importance = Some(0.0);
213        // Explicit value bypasses the heuristic entirely.
214        assert_eq!(score(&c), 0.0);
215    }
216
217    #[test]
218    fn explicit_importance_is_honored_with_clamp() {
219        let mut c = nc(ChunkKind::Chat);
220        c.importance = Some(1.7);
221        assert_eq!(score(&c), 1.0);
222        c.importance = Some(-0.3);
223        assert_eq!(score(&c), 0.0);
224    }
225
226    #[test]
227    fn chat_baseline_with_floor() {
228        let s = score(&nc(ChunkKind::Chat));
229        // Baseline 0.5 — well above the 0.1 floor, so floor doesn't activate.
230        assert_eq!(s, 0.5);
231    }
232}