1use 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#[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 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}