Skip to main content

locus_sdk/application/
memory_reflex.rs

1//! Reactive memory primitive.
2//!
3//! Passive primitives run when the caller already chose an operation. This
4//! service is the step before that. A stimulus comes in — the payload an event
5//! bus would have delivered — and an attached System 1 decider answers five
6//! typed questions in one pass. The gate turns those answers into a
7//! [`MemoryReflex`] envelope.
8//!
9//! The envelope is what a host publishes. This service does not subscribe,
10//! publish, buffer, or touch a node store. Runnable recall, find, aggregate,
11//! and persist payloads are filled only when the gate accepts the decision.
12
13use 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
38/// The five questions every memory reflex asks a System 1 model.
39pub 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
107/// Gates System 1 answers into a memory envelope a host can put on its own bus.
108pub 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    /// Attach the offline lexical decider. It speaks the catalog; it is not a checkpoint.
132    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    /// The exact System 1 request `decide` would send. A host that already ran
146    /// Laya can POST this body, parse the response, and call [`Self::apply`].
147    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    /// Run the attached decider, then gate the answers.
161    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    /// Gate a forward pass the host already ran. The attached decider is not called.
170    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}