1use std::collections::BTreeMap;
14use std::sync::Arc;
15
16use anyhow::{Result, bail};
17use serde_json::json;
18
19use crate::domain::memory::{
20 MEMORY_SCHEMA_VERSION, MemoryAggregateRequest, MemoryFilter, MemoryFindRequest, MemoryGroupBy,
21 MemoryPage, MemoryRecallRequest, clamp_limit,
22};
23use crate::domain::reflex::{
24 MEMORY_ESCALATE_TOPIC, MemoryAction, MemoryPersistHint, MemoryPropositions, MemoryReflex,
25 MemoryReflexKind, MemoryStimulus, ReflexGate, ReflexPolicy, SALIENCE_RUBRIC,
26};
27use crate::domain::system1::{
28 DecisionAnswer, DecisionQuestion, System1Decider, System1Request, System1Response,
29};
30use crate::infrastructure::system1::HeuristicSystem1;
31
32const QUESTION_ACTION: &str = "action";
33const QUESTION_SALIENCE: &str = "salience";
34const QUESTION_REFERENCES: &str = "references_prior";
35const QUESTION_PERSIST: &str = "should_persist";
36const QUESTION_SYSTEM2: &str = "needs_system2";
37
38pub fn memory_reflex_questions() -> BTreeMap<String, DecisionQuestion> {
40 let mut action = BTreeMap::new();
41 action.insert(
42 "ignore".to_string(),
43 "acknowledgement, small talk, or a self-contained turn that should not read or write memory"
44 .to_string(),
45 );
46 action.insert(
47 "recall".to_string(),
48 "the state depends on earlier conversation or asks to retrieve ranked prior context"
49 .to_string(),
50 );
51 action.insert(
52 "find".to_string(),
53 "the state asks for a filtered lookup by phrase, tag, session, or time rather than ranked recall"
54 .to_string(),
55 );
56 action.insert(
57 "persist".to_string(),
58 "the state states a durable fact, preference, correction, or instruction that should be stored"
59 .to_string(),
60 );
61 action.insert(
62 "explain".to_string(),
63 "the state asks why a prior memory was used or how a remembered answer was grounded"
64 .to_string(),
65 );
66 action.insert(
67 "aggregate".to_string(),
68 "the state asks for a count, summary, trend, or rollup across stored memory".to_string(),
69 );
70
71 BTreeMap::from([
72 (
73 QUESTION_ACTION.to_string(),
74 DecisionQuestion::Choice {
75 instructions: "Which single memory operation should run for this state? Pick ignore when the state has no durable or historical memory consequence.".to_string(),
76 criteria: action,
77 },
78 ),
79 (
80 QUESTION_SALIENCE.to_string(),
81 DecisionQuestion::Score {
82 instructions: "How strongly must memory be touched before the next step can be correct?".to_string(),
83 criteria: SALIENCE_RUBRIC.iter().map(|label| (*label).to_string()).collect(),
84 },
85 ),
86 (
87 QUESTION_REFERENCES.to_string(),
88 DecisionQuestion::Noul {
89 instructions: "Does this state depend on something already stored in memory?".to_string(),
90 },
91 ),
92 (
93 QUESTION_PERSIST.to_string(),
94 DecisionQuestion::Noul {
95 instructions: "Should a new durable memory be written from this state?".to_string(),
96 },
97 ),
98 (
99 QUESTION_SYSTEM2.to_string(),
100 DecisionQuestion::Noul {
101 instructions: "Is this too ambiguous, contradictory, or high-stakes for a typed memory decision alone?".to_string(),
102 },
103 ),
104 ])
105}
106
107pub struct MemoryReflexService {
109 decider: Arc<dyn System1Decider>,
110 policy: ReflexPolicy,
111}
112
113impl std::fmt::Debug for MemoryReflexService {
114 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 formatter
116 .debug_struct("MemoryReflexService")
117 .field("decider_id", &self.decider.decider_id())
118 .field("policy", &self.policy)
119 .finish()
120 }
121}
122
123impl MemoryReflexService {
124 pub fn new(decider: Arc<dyn System1Decider>) -> Self {
125 Self {
126 decider,
127 policy: ReflexPolicy::default(),
128 }
129 }
130
131 pub fn heuristic() -> Self {
133 Self::new(Arc::new(HeuristicSystem1))
134 }
135
136 pub fn with_policy(mut self, policy: ReflexPolicy) -> Self {
137 self.policy = clamp_policy(policy);
138 self
139 }
140
141 pub fn policy(&self) -> ReflexPolicy {
142 self.policy
143 }
144
145 pub fn request_for(&self, stimulus: &MemoryStimulus) -> System1Request {
148 System1Request {
149 state: json!({
150 "role": stimulus.role,
151 "text": stimulus.text,
152 "session_ids": stimulus.scope.session_ids,
153 "metadata": stimulus.metadata,
154 }),
155 questions: memory_reflex_questions(),
156 model: None,
157 }
158 }
159
160 pub async fn decide(&self, stimulus: &MemoryStimulus) -> Result<MemoryReflex> {
162 if stimulus.text.trim().is_empty() {
163 return Ok(blank_reflex(stimulus));
164 }
165 let decision = self.decider.predict(&self.request_for(stimulus)).await?;
166 self.apply(stimulus, &decision)
167 }
168
169 pub fn apply(
171 &self,
172 stimulus: &MemoryStimulus,
173 decision: &System1Response,
174 ) -> Result<MemoryReflex> {
175 if stimulus.text.trim().is_empty() {
176 return Ok(blank_reflex(stimulus));
177 }
178 let parsed = parse_decision(decision)?;
179 let (kind, gate) = gate(&parsed, &self.policy);
180 let companions = companions(kind, parsed.action, &parsed.propositions, &self.policy);
181 let (recall, find, aggregate, persist) = if kind == MemoryReflexKind::Dispatch {
182 payloads(stimulus, parsed.action, &companions, self.policy)
183 } else {
184 (None, None, None, None)
185 };
186 let topic = match kind {
187 MemoryReflexKind::Escalate => MEMORY_ESCALATE_TOPIC.to_string(),
188 MemoryReflexKind::Ignore => MemoryAction::Ignore.topic().to_string(),
189 MemoryReflexKind::Dispatch => parsed.action.topic().to_string(),
190 };
191 let decider_id = if decision.decider_id.is_empty() {
192 self.decider.decider_id().to_string()
193 } else {
194 decision.decider_id.clone()
195 };
196
197 Ok(MemoryReflex {
198 schema_version: MEMORY_SCHEMA_VERSION.to_string(),
199 stimulus_id: stimulus_id(stimulus),
200 stimulus_text: stimulus.text.clone(),
201 role: stimulus.role.clone(),
202 scope: stimulus.scope.clone(),
203 kind,
204 action: parsed.action,
205 topic,
206 salience: parsed.salience,
207 salience_label: parsed.salience_label,
208 salience_confidence: parsed.salience_confidence,
209 confidence: parsed.confidence,
210 propositions: parsed.propositions,
211 gate,
212 companions,
213 recall,
214 find,
215 aggregate,
216 persist,
217 decider_id,
218 checkpoint: decision.checkpoint.clone(),
219 metadata: stimulus.metadata.clone(),
220 })
221 }
222}
223
224struct ParsedDecision {
225 action: MemoryAction,
226 confidence: f32,
227 salience: f32,
228 salience_label: String,
229 salience_confidence: f32,
230 propositions: MemoryPropositions,
231}
232
233fn parse_decision(decision: &System1Response) -> Result<ParsedDecision> {
234 let action_answer = required(decision, QUESTION_ACTION)?;
235 let salience_answer = required(decision, QUESTION_SALIENCE)?;
236 let references = noul(
237 required(decision, QUESTION_REFERENCES)?,
238 QUESTION_REFERENCES,
239 )?;
240 let should_persist = noul(required(decision, QUESTION_PERSIST)?, QUESTION_PERSIST)?;
241 let needs_system2 = noul(required(decision, QUESTION_SYSTEM2)?, QUESTION_SYSTEM2)?;
242
243 let (choice, confidence) = match action_answer {
244 DecisionAnswer::Choice {
245 choice, confidence, ..
246 } => (choice, *confidence),
247 _ => bail!("system 1 answer `{QUESTION_ACTION}` must be a choice"),
248 };
249 let action = MemoryAction::parse(choice)
250 .ok_or_else(|| anyhow::anyhow!("system 1 choice `{choice}` is not a memory action"))?;
251
252 let (score, max, label, salience_confidence) = match salience_answer {
253 DecisionAnswer::Score {
254 score,
255 max,
256 label,
257 confidence,
258 } => (*score, *max, label.clone(), *confidence),
259 _ => bail!("system 1 answer `{QUESTION_SALIENCE}` must be a score"),
260 };
261 let rubric_max = (SALIENCE_RUBRIC.len() - 1) as f32;
262 let max = max.filter(|value| *value > 0.0).unwrap_or(rubric_max);
263 let salience = if max <= 0.0 {
264 0.0
265 } else {
266 (score / max).clamp(0.0, 1.0)
267 };
268 let salience_label = label
269 .filter(|value| !value.is_empty())
270 .unwrap_or_else(|| rubric_label(score, max));
271
272 Ok(ParsedDecision {
273 action,
274 confidence,
275 salience,
276 salience_label,
277 salience_confidence,
278 propositions: MemoryPropositions {
279 references_prior: references,
280 should_persist,
281 needs_system2,
282 },
283 })
284}
285
286fn gate(parsed: &ParsedDecision, policy: &ReflexPolicy) -> (MemoryReflexKind, ReflexGate) {
287 if parsed.propositions.needs_system2 >= policy.escalate_at {
288 return (MemoryReflexKind::Escalate, ReflexGate::System2Required);
289 }
290 if parsed.confidence < policy.min_choice_confidence
291 || parsed.salience_confidence < policy.min_choice_confidence
292 {
293 return (MemoryReflexKind::Escalate, ReflexGate::LowConfidence);
294 }
295 if parsed.salience < policy.min_salience {
296 return if proposition_claims(parsed.action, &parsed.propositions, policy) {
297 (
298 MemoryReflexKind::Escalate,
299 ReflexGate::PropositionDisagreement,
300 )
301 } else {
302 (MemoryReflexKind::Ignore, ReflexGate::BelowSalience)
303 };
304 }
305 match parsed.action {
306 MemoryAction::Ignore => {
307 if parsed.propositions.references_prior >= policy.read_floor
308 || parsed.propositions.should_persist >= policy.write_floor
309 {
310 (
311 MemoryReflexKind::Escalate,
312 ReflexGate::PropositionDisagreement,
313 )
314 } else {
315 (MemoryReflexKind::Ignore, ReflexGate::Accepted)
316 }
317 }
318 MemoryAction::Persist => {
319 if parsed.propositions.should_persist < policy.write_floor {
320 (
321 MemoryReflexKind::Escalate,
322 ReflexGate::PropositionDisagreement,
323 )
324 } else {
325 (MemoryReflexKind::Dispatch, ReflexGate::Accepted)
326 }
327 }
328 MemoryAction::Recall
329 | MemoryAction::Find
330 | MemoryAction::Explain
331 | MemoryAction::Aggregate => {
332 if parsed.propositions.references_prior < policy.read_floor {
333 (
334 MemoryReflexKind::Escalate,
335 ReflexGate::PropositionDisagreement,
336 )
337 } else {
338 (MemoryReflexKind::Dispatch, ReflexGate::Accepted)
339 }
340 }
341 }
342}
343
344fn proposition_claims(
345 action: MemoryAction,
346 propositions: &MemoryPropositions,
347 policy: &ReflexPolicy,
348) -> bool {
349 match action {
350 MemoryAction::Ignore => {
351 propositions.references_prior >= policy.read_floor
352 || propositions.should_persist >= policy.write_floor
353 }
354 MemoryAction::Persist => propositions.should_persist >= policy.write_floor,
355 MemoryAction::Recall
356 | MemoryAction::Find
357 | MemoryAction::Explain
358 | MemoryAction::Aggregate => propositions.references_prior >= policy.read_floor,
359 }
360}
361
362fn companions(
363 kind: MemoryReflexKind,
364 action: MemoryAction,
365 propositions: &MemoryPropositions,
366 policy: &ReflexPolicy,
367) -> Vec<MemoryAction> {
368 if kind != MemoryReflexKind::Dispatch {
369 return Vec::new();
370 }
371 let mut companions = Vec::new();
372 if action.is_read() && propositions.should_persist >= policy.write_floor {
373 companions.push(MemoryAction::Persist);
374 }
375 if action == MemoryAction::Persist && propositions.references_prior >= policy.read_floor {
376 companions.push(MemoryAction::Recall);
377 }
378 companions
379}
380
381fn payloads(
382 stimulus: &MemoryStimulus,
383 action: MemoryAction,
384 companions: &[MemoryAction],
385 policy: ReflexPolicy,
386) -> (
387 Option<MemoryRecallRequest>,
388 Option<MemoryFindRequest>,
389 Option<MemoryAggregateRequest>,
390 Option<MemoryPersistHint>,
391) {
392 let wants = |candidate: MemoryAction| action == candidate || companions.contains(&candidate);
393 let recall = if wants(MemoryAction::Recall) || action == MemoryAction::Explain {
394 Some(MemoryRecallRequest {
395 scope: stimulus.scope.clone(),
396 page: MemoryPage {
397 limit: policy.page_limit,
398 cursor: None,
399 },
400 query_text: Some(stimulus.text.clone()),
401 ..Default::default()
402 })
403 } else {
404 None
405 };
406 let find = if wants(MemoryAction::Find) {
407 Some(MemoryFindRequest {
408 scope: stimulus.scope.clone(),
409 filter: MemoryFilter {
410 text_contains: Some(stimulus.text.clone()),
411 ..Default::default()
412 },
413 page: MemoryPage {
414 limit: policy.page_limit,
415 cursor: None,
416 },
417 ..Default::default()
418 })
419 } else {
420 None
421 };
422 let aggregate = if wants(MemoryAction::Aggregate) {
423 Some(MemoryAggregateRequest {
424 scope: stimulus.scope.clone(),
425 group_by: MemoryGroupBy::DateDay,
426 max_groups: 31,
427 max_nodes: 1000,
428 ..Default::default()
429 })
430 } else {
431 None
432 };
433 let persist = if wants(MemoryAction::Persist) {
434 Some(MemoryPersistHint {
435 text: stimulus.text.clone(),
436 role: stimulus.role.clone(),
437 })
438 } else {
439 None
440 };
441 (recall, find, aggregate, persist)
442}
443
444fn blank_reflex(stimulus: &MemoryStimulus) -> MemoryReflex {
445 MemoryReflex {
446 schema_version: MEMORY_SCHEMA_VERSION.to_string(),
447 stimulus_id: stimulus_id(stimulus),
448 stimulus_text: stimulus.text.clone(),
449 role: stimulus.role.clone(),
450 scope: stimulus.scope.clone(),
451 kind: MemoryReflexKind::Ignore,
452 action: MemoryAction::Ignore,
453 topic: MemoryAction::Ignore.topic().to_string(),
454 salience: 0.0,
455 salience_label: SALIENCE_RUBRIC[0].to_string(),
456 salience_confidence: 1.0,
457 confidence: 1.0,
458 propositions: MemoryPropositions::default(),
459 gate: ReflexGate::BlankStimulus,
460 companions: Vec::new(),
461 recall: None,
462 find: None,
463 aggregate: None,
464 persist: None,
465 decider_id: "none".to_string(),
466 checkpoint: None,
467 metadata: stimulus.metadata.clone(),
468 }
469}
470
471fn stimulus_id(stimulus: &MemoryStimulus) -> String {
472 if let Some(id) = stimulus
473 .id
474 .as_deref()
475 .map(str::trim)
476 .filter(|id| !id.is_empty())
477 {
478 return id.to_string();
479 }
480 let mut hash: u64 = 0xcbf29ce484222325;
481 let mix = |hash: &mut u64, bytes: &[u8]| {
482 for byte in bytes {
483 *hash ^= u64::from(*byte);
484 *hash = hash.wrapping_mul(0x100000001b3);
485 }
486 *hash ^= 0xff;
487 };
488 mix(&mut hash, stimulus.role.as_deref().unwrap_or("").as_bytes());
489 if let Some(sessions) = &stimulus.scope.session_ids {
490 for session in sessions {
491 mix(&mut hash, session.as_bytes());
492 }
493 }
494 mix(&mut hash, stimulus.text.as_bytes());
495 format!("stim-{hash:016x}")
496}
497
498fn required<'a>(decision: &'a System1Response, name: &str) -> Result<&'a DecisionAnswer> {
499 decision
500 .answers
501 .get(name)
502 .ok_or_else(|| anyhow::anyhow!("system 1 response is missing `{name}`"))
503}
504
505fn noul(answer: &DecisionAnswer, name: &str) -> Result<f32> {
506 match answer {
507 DecisionAnswer::Noul { probability } => Ok(*probability),
508 _ => bail!("system 1 answer `{name}` must be a noul"),
509 }
510}
511
512fn rubric_label(score: f32, max: f32) -> String {
513 let steps = (SALIENCE_RUBRIC.len() - 1) as f32;
514 let idx = if max <= 0.0 {
515 0
516 } else {
517 (score / max * steps).round() as usize
518 };
519 SALIENCE_RUBRIC[idx.min(SALIENCE_RUBRIC.len() - 1)].to_string()
520}
521
522fn clamp_policy(policy: ReflexPolicy) -> ReflexPolicy {
523 ReflexPolicy {
524 min_choice_confidence: unit(policy.min_choice_confidence),
525 min_salience: unit(policy.min_salience),
526 read_floor: unit(policy.read_floor),
527 write_floor: unit(policy.write_floor),
528 escalate_at: unit(policy.escalate_at),
529 page_limit: clamp_limit(policy.page_limit),
530 }
531}
532
533fn unit(value: f32) -> f32 {
534 if value.is_finite() {
535 value.clamp(0.0, 1.0)
536 } else {
537 0.0
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use std::sync::Arc;
544
545 use async_trait::async_trait;
546 use serde_json::json;
547
548 use super::{MemoryReflexService, memory_reflex_questions};
549 use crate::domain::memory::MemoryScope;
550 use crate::domain::reflex::{
551 MemoryAction, MemoryReflexKind, MemoryStimulus, ReflexGate, ReflexPolicy,
552 };
553 use crate::domain::system1::{DecisionAnswer, System1Decider, System1Request, System1Response};
554 use crate::interface::dto::MemoryReflexResponseDto;
555
556 fn stimulus(text: &str) -> MemoryStimulus {
557 MemoryStimulus {
558 text: text.to_string(),
559 role: Some("user".to_string()),
560 scope: MemoryScope {
561 session_ids: Some(vec!["s-1".to_string()]),
562 ..Default::default()
563 },
564 metadata: serde_json::Map::from_iter([("correlation".to_string(), json!("c-1"))]),
565 ..Default::default()
566 }
567 }
568
569 fn scripted(
570 action: &str,
571 confidence: f32,
572 salience: f32,
573 salience_confidence: f32,
574 references_prior: f32,
575 should_persist: f32,
576 needs_system2: f32,
577 ) -> System1Response {
578 let mut answers = std::collections::BTreeMap::new();
579 answers.insert(
580 "action".to_string(),
581 DecisionAnswer::Choice {
582 choice: action.to_string(),
583 confidence,
584 probabilities: std::collections::BTreeMap::new(),
585 },
586 );
587 answers.insert(
588 "salience".to_string(),
589 DecisionAnswer::Score {
590 score: salience * 3.0,
591 max: Some(3.0),
592 label: None,
593 confidence: salience_confidence,
594 },
595 );
596 for (name, probability) in [
597 ("references_prior", references_prior),
598 ("should_persist", should_persist),
599 ("needs_system2", needs_system2),
600 ] {
601 answers.insert(name.to_string(), DecisionAnswer::Noul { probability });
602 }
603 System1Response {
604 decider_id: "scripted".to_string(),
605 checkpoint: Some("scripted".to_string()),
606 answers,
607 }
608 }
609
610 struct Bomb;
611
612 #[async_trait]
613 impl System1Decider for Bomb {
614 fn decider_id(&self) -> &str {
615 "bomb"
616 }
617
618 async fn predict(&self, _request: &System1Request) -> anyhow::Result<System1Response> {
619 anyhow::bail!("decider should not run");
620 }
621 }
622
623 #[tokio::test]
624 async fn blank_stimulus_skips_the_decider() {
625 let service = MemoryReflexService::new(Arc::new(Bomb));
626 let reflex = service
627 .decide(&stimulus(" \n"))
628 .await
629 .expect("blank ignores");
630 assert_eq!(reflex.kind, MemoryReflexKind::Ignore);
631 assert_eq!(reflex.gate, ReflexGate::BlankStimulus);
632 assert!(reflex.recall.is_none());
633 assert_eq!(reflex.decider_id, "none");
634 }
635
636 #[tokio::test]
637 async fn apply_does_not_call_the_decider() {
638 let service = MemoryReflexService::new(Arc::new(Bomb));
639 let reflex = service
640 .apply(
641 &stimulus("do you remember the refund"),
642 &scripted("recall", 0.92, 0.8, 0.9, 0.88, 0.1, 0.1),
643 )
644 .expect("apply gates a finished forward pass");
645 assert_eq!(reflex.kind, MemoryReflexKind::Dispatch);
646 assert_eq!(reflex.action, MemoryAction::Recall);
647 assert_eq!(reflex.topic, "locus.memory.recall");
648 assert_eq!(
649 reflex
650 .recall
651 .and_then(|recall| recall.query_text)
652 .as_deref(),
653 Some("do you remember the refund")
654 );
655 }
656
657 #[tokio::test]
658 async fn heuristic_routes_the_catalog() {
659 let service = MemoryReflexService::heuristic();
660 let recall = service
661 .decide(&stimulus("do you remember what we discussed about refunds"))
662 .await
663 .expect("recall");
664 assert_eq!(recall.kind, MemoryReflexKind::Dispatch);
665 assert_eq!(recall.action, MemoryAction::Recall);
666 assert!(recall.salience_label.contains("relevant"));
667 assert_eq!(recall.metadata.get("correlation"), Some(&json!("c-1")));
668 assert_eq!(recall.recall.unwrap().page.limit, 8);
669
670 let persist = service
671 .decide(&stimulus("please remember that I prefer aisle seats"))
672 .await
673 .expect("persist");
674 assert_eq!(persist.kind, MemoryReflexKind::Dispatch);
675 assert_eq!(persist.action, MemoryAction::Persist);
676 assert_eq!(
677 persist.persist.unwrap().text,
678 "please remember that I prefer aisle seats"
679 );
680 assert!(persist.recall.is_none());
681
682 let find = service
683 .decide(&stimulus("find the nodes tagged billing"))
684 .await
685 .expect("find");
686 assert_eq!(find.action, MemoryAction::Find);
687 assert_eq!(find.topic, "locus.memory.find");
688 assert!(find.find.unwrap().filter.text_contains.is_some());
689
690 let aggregate = service
691 .decide(&stimulus("summarize what we decided over the last week"))
692 .await
693 .expect("aggregate");
694 assert_eq!(aggregate.action, MemoryAction::Aggregate);
695 assert!(aggregate.aggregate.is_some());
696
697 let explain = service
698 .decide(&stimulus("why did you recall that memory"))
699 .await
700 .expect("explain");
701 assert_eq!(explain.action, MemoryAction::Explain);
702 assert!(explain.recall.is_some());
703
704 let thanks = service.decide(&stimulus("thanks")).await.expect("thanks");
705 assert_eq!(thanks.kind, MemoryReflexKind::Ignore);
706 assert_eq!(thanks.gate, ReflexGate::BelowSalience);
707 assert!(thanks.recall.is_none());
708 assert_eq!(thanks.topic, "locus.memory.ignore");
709
710 let uncertain = service
711 .decide(&stimulus(
712 "the quarterly plan needs another look before friday",
713 ))
714 .await
715 .expect("uncertain");
716 assert_eq!(uncertain.kind, MemoryReflexKind::Escalate);
717 assert_eq!(uncertain.gate, ReflexGate::System2Required);
718 assert!(uncertain.recall.is_none());
719 assert_eq!(uncertain.topic, "locus.memory.escalate");
720 }
721
722 #[test]
723 fn low_confidence_and_disagreement_do_not_dispatch() {
724 let service = MemoryReflexService::heuristic();
725 let low = service
726 .apply(
727 &stimulus("hold this"),
728 &scripted("recall", 0.2, 0.9, 0.9, 0.9, 0.1, 0.1),
729 )
730 .expect("low confidence");
731 assert_eq!(low.kind, MemoryReflexKind::Escalate);
732 assert_eq!(low.gate, ReflexGate::LowConfidence);
733 assert!(low.recall.is_none());
734
735 let split = service
736 .apply(
737 &stimulus("hold this"),
738 &scripted("recall", 0.92, 0.8, 0.9, 0.1, 0.05, 0.1),
739 )
740 .expect("disagreement");
741 assert_eq!(split.kind, MemoryReflexKind::Escalate);
742 assert_eq!(split.gate, ReflexGate::PropositionDisagreement);
743
744 let quiet = service
745 .apply(
746 &stimulus("hold this"),
747 &scripted("recall", 0.92, 0.1, 0.9, 0.9, 0.1, 0.1),
748 )
749 .expect("low salience still claims memory");
750 assert_eq!(quiet.kind, MemoryReflexKind::Escalate);
751 assert_eq!(quiet.gate, ReflexGate::PropositionDisagreement);
752
753 let drop = service
754 .apply(
755 &stimulus("hold this"),
756 &scripted("ignore", 0.92, 0.1, 0.9, 0.1, 0.1, 0.1),
757 )
758 .expect("drop");
759 assert_eq!(drop.kind, MemoryReflexKind::Ignore);
760 assert_eq!(drop.gate, ReflexGate::BelowSalience);
761 assert_eq!(drop.topic, "locus.memory.ignore");
762 }
763
764 #[test]
765 fn persist_with_a_prior_reference_carries_a_recall_companion() {
766 let service = MemoryReflexService::heuristic();
767 let reflex = service
768 .apply(
769 &stimulus("save the preference and the earlier note"),
770 &scripted("persist", 0.9, 0.8, 0.9, 0.8, 0.9, 0.1),
771 )
772 .expect("companion");
773 assert_eq!(reflex.kind, MemoryReflexKind::Dispatch);
774 assert_eq!(reflex.companions, vec![MemoryAction::Recall]);
775 assert!(reflex.persist.is_some());
776 assert!(reflex.recall.is_some());
777 }
778
779 #[test]
780 fn laya_wire_body_round_trips_through_the_gate() {
781 let service = MemoryReflexService::heuristic();
782 let incoming = stimulus("do you remember the duplicate charge");
783 let request = service.request_for(&incoming);
784 assert_eq!(
785 request.questions.keys().cloned().collect::<Vec<_>>(),
786 memory_reflex_questions().into_keys().collect::<Vec<_>>()
787 );
788 let body = json!({
789 "routing": {"model": "typed-decisions"},
790 "answers": {
791 "action": {"choice": "recall", "confidence": 0.94},
792 "salience": {"score": 2.1, "confidence": 0.9},
793 "references_prior": {"noul": 0.91},
794 "should_persist": {"noul": 0.08},
795 "needs_system2": {"noul": 0.12}
796 }
797 });
798 let parsed = System1Response::parse_wire(&request.questions, &body).expect("parse");
799 let reflex = service.apply(&incoming, &parsed).expect("gate");
800 assert_eq!(reflex.checkpoint.as_deref(), Some("typed-decisions"));
801 assert_eq!(reflex.kind, MemoryReflexKind::Dispatch);
802
803 let wire = serde_json::to_value(MemoryReflexResponseDto::from(reflex)).expect("dto");
804 let back: MemoryReflexResponseDto =
805 serde_json::from_value(wire.clone()).expect("round trip");
806 assert_eq!(back.topic, "locus.memory.recall");
807 assert_eq!(back.schema_version, "locus-sdk.memory.v4");
808 assert_eq!(
809 back.recall.unwrap().query_text.as_deref(),
810 Some("do you remember the duplicate charge")
811 );
812 assert_eq!(wire["scope"]["sessionIds"][0], "s-1");
813 }
814
815 #[test]
816 fn unknown_choice_is_rejected() {
817 let service = MemoryReflexService::heuristic().with_policy(ReflexPolicy {
818 page_limit: 0,
819 ..ReflexPolicy::default()
820 });
821 assert_eq!(service.policy().page_limit, 1);
822 let error = service
823 .apply(
824 &stimulus("hold this"),
825 &scripted("teleport", 0.99, 0.9, 0.9, 0.9, 0.1, 0.1),
826 )
827 .unwrap_err();
828 assert!(error.to_string().contains("teleport"));
829 }
830
831 #[test]
832 fn explicit_stimulus_id_is_kept() {
833 let mut incoming = stimulus("thanks");
834 incoming.id = Some(" bus-9 ".to_string());
835 assert_eq!(super::stimulus_id(&incoming), "bus-9");
836 }
837}