Skip to main content

locus_sdk/infrastructure/system1/
heuristic.rs

1//! Offline System 1 stand-in for the memory reflex catalog.
2//!
3//! Cue counts are not a calibrated checkpoint. They exist so the gate, the
4//! envelope, and tests have a decider that speaks the same five questions.
5//! Attach [`super::HttpSystem1`] or call `MemoryReflexService::apply` when a
6//! real Laya forward pass is available.
7
8use std::collections::BTreeMap;
9
10use anyhow::{Result, bail};
11use async_trait::async_trait;
12use serde_json::Value;
13
14use crate::domain::reflex::{MemoryAction, SALIENCE_RUBRIC};
15use crate::domain::system1::{
16    DecisionAnswer, DecisionQuestion, System1Decider, System1Request, System1Response,
17};
18
19const RECALL: &[&str] = &[
20    "do you remember",
21    "remember when",
22    "last time",
23    "you said",
24    "what did i",
25    "what did we",
26    "we discussed",
27    "earlier you",
28    "from before",
29    "previous time",
30    "as i mentioned",
31];
32
33const PERSIST: &[&str] = &[
34    "remember that",
35    "please remember",
36    "note that",
37    "my name is",
38    "i prefer",
39    "from now on",
40    "don't forget",
41    "do not forget",
42    "save this",
43    "i always",
44    "i never",
45    "keep in mind",
46];
47
48const FIND: &[&str] = &[
49    "find the",
50    "list the",
51    "show me",
52    "look up",
53    "search for",
54    "tagged",
55    "filter by",
56];
57
58const AGGREGATE: &[&str] = &[
59    "summarize",
60    "summary of",
61    "how many",
62    "rollup",
63    "over the last",
64    "this week",
65    "count the",
66];
67
68const EXPLAIN: &[&str] = &[
69    "why did you recall",
70    "why did you remember",
71    "how did you know",
72    "which memory",
73    "explain the retrieval",
74    "why that memory",
75];
76
77const ACKS: &[&str] = &[
78    "ok",
79    "okay",
80    "k",
81    "thanks",
82    "thank you",
83    "thx",
84    "lol",
85    "yes",
86    "no",
87    "yep",
88    "nope",
89    "sure",
90    "cool",
91    "got it",
92    "hi",
93    "hello",
94    "hey",
95];
96
97struct Signals {
98    action: MemoryAction,
99    confidence: f32,
100    salience_score: f32,
101    references_prior: f32,
102    should_persist: f32,
103    needs_system2: f32,
104}
105
106/// Lexical decider for the five memory-reflex questions.
107#[derive(Debug, Default, Clone, Copy)]
108pub struct HeuristicSystem1;
109
110#[async_trait]
111impl System1Decider for HeuristicSystem1 {
112    fn decider_id(&self) -> &str {
113        "heuristic"
114    }
115
116    async fn predict(&self, request: &System1Request) -> Result<System1Response> {
117        let signals = assess(&text_of(&request.state));
118        let mut answers = BTreeMap::new();
119        for (name, question) in &request.questions {
120            answers.insert(name.clone(), answer_for(name, question, &signals)?);
121        }
122        Ok(System1Response {
123            decider_id: self.decider_id().to_string(),
124            checkpoint: Some("heuristic".to_string()),
125            answers,
126        })
127    }
128}
129
130fn answer_for(
131    name: &str,
132    question: &DecisionQuestion,
133    signals: &Signals,
134) -> Result<DecisionAnswer> {
135    match (name, question) {
136        ("action", DecisionQuestion::Choice { .. }) => Ok(DecisionAnswer::Choice {
137            choice: signals.action.as_str().to_string(),
138            confidence: signals.confidence,
139            probabilities: BTreeMap::from([(
140                signals.action.as_str().to_string(),
141                signals.confidence,
142            )]),
143        }),
144        ("salience", DecisionQuestion::Score { .. }) => Ok(DecisionAnswer::Score {
145            score: signals.salience_score,
146            max: Some((SALIENCE_RUBRIC.len() - 1) as f32),
147            label: Some(rubric_label(signals.salience_score)),
148            confidence: signals.confidence,
149        }),
150        ("references_prior", DecisionQuestion::Noul { .. }) => Ok(DecisionAnswer::Noul {
151            probability: signals.references_prior,
152        }),
153        ("should_persist", DecisionQuestion::Noul { .. }) => Ok(DecisionAnswer::Noul {
154            probability: signals.should_persist,
155        }),
156        ("needs_system2", DecisionQuestion::Noul { .. }) => Ok(DecisionAnswer::Noul {
157            probability: signals.needs_system2,
158        }),
159        _ => bail!("heuristic system 1 only answers the memory reflex catalog, not `{name}`"),
160    }
161}
162
163fn assess(text: &str) -> Signals {
164    let normalized = text.trim().to_lowercase();
165    if is_ack(&normalized) {
166        return Signals {
167            action: MemoryAction::Ignore,
168            confidence: 0.93,
169            salience_score: 0.2,
170            references_prior: 0.05,
171            should_persist: 0.04,
172            needs_system2: 0.08,
173        };
174    }
175
176    let recall_hits = hits(&normalized, RECALL);
177    let persist_hits = hits(&normalized, PERSIST);
178    let find_hits = hits(&normalized, FIND);
179    let aggregate_hits = hits(&normalized, AGGREGATE);
180    let explain_hits = hits(&normalized, EXPLAIN);
181    let read_hits = recall_hits + find_hits + aggregate_hits + explain_hits;
182
183    if read_hits + persist_hits == 0 {
184        let long = normalized.chars().count() > 24;
185        return if long {
186            Signals {
187                action: MemoryAction::Ignore,
188                confidence: 0.4,
189                salience_score: 1.3,
190                references_prior: 0.12,
191                should_persist: 0.1,
192                needs_system2: 0.86,
193            }
194        } else {
195            Signals {
196                action: MemoryAction::Ignore,
197                confidence: 0.84,
198                salience_score: 0.2,
199                references_prior: 0.08,
200                should_persist: 0.06,
201                needs_system2: 0.1,
202            }
203        };
204    }
205
206    // Earlier entries win ties, so a more specific cue beats a general one.
207    let scored = [
208        (MemoryAction::Explain, explain_hits as f32),
209        (MemoryAction::Persist, persist_hits as f32),
210        (MemoryAction::Aggregate, aggregate_hits as f32),
211        (MemoryAction::Find, find_hits as f32),
212        (MemoryAction::Recall, recall_hits as f32),
213        (MemoryAction::Ignore, 0.0),
214    ];
215    let best = scored
216        .iter()
217        .map(|(_, score)| *score)
218        .fold(0.0_f32, f32::max);
219    let action = scored
220        .iter()
221        .find(|(_, score)| (*score - best).abs() < f32::EPSILON)
222        .map(|(action, _)| *action)
223        .unwrap_or(MemoryAction::Ignore);
224    let second = scored
225        .iter()
226        .filter(|(candidate, _)| *candidate != action)
227        .map(|(_, score)| *score)
228        .fold(0.0_f32, f32::max);
229    let margin = (best - second).max(0.0);
230    let mut confidence = (0.52 + margin * 0.2).clamp(0.52, 0.96);
231    let mut needs_system2: f32 = if margin < 0.5 && second > 0.0 {
232        0.74
233    } else {
234        0.1
235    };
236    if read_hits > 0 && persist_hits > 0 {
237        needs_system2 = needs_system2.max(0.84);
238        confidence = confidence.min(0.48);
239    }
240    let salience_score = if best >= 2.5 { 2.8 } else { 2.2 };
241
242    Signals {
243        action,
244        confidence,
245        salience_score,
246        references_prior: probability_from_hits(read_hits, 0.72, 0.1),
247        should_persist: probability_from_hits(persist_hits, 0.78, 0.08),
248        needs_system2,
249    }
250}
251
252fn probability_from_hits(hits: usize, base: f32, step: f32) -> f32 {
253    if hits == 0 {
254        0.08
255    } else {
256        (base + (hits - 1) as f32 * step).clamp(0.0, 0.97)
257    }
258}
259
260fn hits(text: &str, phrases: &[&str]) -> usize {
261    phrases
262        .iter()
263        .filter(|phrase| text.contains(*phrase))
264        .count()
265}
266
267fn is_ack(normalized: &str) -> bool {
268    let key = normalized
269        .trim_matches(|c: char| !c.is_alphanumeric() && !c.is_whitespace())
270        .trim();
271    ACKS.contains(&key)
272}
273
274fn rubric_label(score: f32) -> String {
275    let max = (SALIENCE_RUBRIC.len() - 1) as f32;
276    let idx = (score.round().clamp(0.0, max)) as usize;
277    SALIENCE_RUBRIC[idx.min(SALIENCE_RUBRIC.len() - 1)].to_string()
278}
279
280fn text_of(state: &Value) -> String {
281    match state {
282        Value::String(text) => text.clone(),
283        Value::Object(map) => ["text", "body", "message"]
284            .iter()
285            .find_map(|key| map.get(*key).and_then(Value::as_str))
286            .unwrap_or("")
287            .to_string(),
288        other => other.to_string(),
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::assess;
295    use crate::domain::reflex::MemoryAction;
296
297    #[test]
298    fn acknowledgement_stays_quiet() {
299        let signals = assess("Thanks!");
300        assert_eq!(signals.action, MemoryAction::Ignore);
301        assert!(signals.confidence > 0.9);
302        assert!(signals.needs_system2 < 0.2);
303    }
304
305    #[test]
306    fn recall_and_persist_cues_do_not_share_bare_remember() {
307        let recall = assess("do you remember what we discussed about refunds");
308        assert_eq!(recall.action, MemoryAction::Recall);
309        assert!(recall.references_prior > 0.7);
310        assert!(recall.should_persist < 0.2);
311
312        let persist = assess("please remember that I prefer aisle seats");
313        assert_eq!(persist.action, MemoryAction::Persist);
314        assert!(persist.should_persist > 0.8);
315        assert!(persist.references_prior < 0.2);
316    }
317
318    #[test]
319    fn mixed_read_and_write_cues_ask_for_system2() {
320        let signals = assess("please remember that, and do you remember what we discussed");
321        assert!(signals.needs_system2 > 0.8);
322        assert!(signals.confidence < 0.55);
323    }
324}