Skip to main content

cel_memory/
importance.rs

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