Skip to main content

locus_sdk/domain/
reflex.rs

1//! Reactive memory envelope.
2//!
3//! The reflex is the value a host puts on its own event bus. Producing one does
4//! not subscribe, publish, buffer, or open a store.
5
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value};
8
9use crate::domain::memory::{
10    MemoryAggregateRequest, MemoryFindRequest, MemoryRecallRequest, MemoryScope,
11};
12
13/// Ordered salience rubric shared by the reflex questions and every decider.
14pub const SALIENCE_RUBRIC: [&str; 4] = [
15    "none: memory would not change the outcome",
16    "background: memory is optional color",
17    "relevant: memory should be read or written",
18    "blocking: the next step is wrong if memory is skipped",
19];
20
21/// Topic for decisions the gate will not run until a larger model reviews them.
22pub const MEMORY_ESCALATE_TOPIC: &str = "locus.memory.escalate";
23
24/// Memory operation a System 1 `choice` answer is allowed to name.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum MemoryAction {
28    Ignore,
29    Recall,
30    Find,
31    Persist,
32    Explain,
33    Aggregate,
34}
35
36impl MemoryAction {
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Self::Ignore => "ignore",
40            Self::Recall => "recall",
41            Self::Find => "find",
42            Self::Persist => "persist",
43            Self::Explain => "explain",
44            Self::Aggregate => "aggregate",
45        }
46    }
47
48    pub fn parse(value: &str) -> Option<Self> {
49        match value {
50            "ignore" => Some(Self::Ignore),
51            "recall" => Some(Self::Recall),
52            "find" => Some(Self::Find),
53            "persist" => Some(Self::Persist),
54            "explain" => Some(Self::Explain),
55            "aggregate" => Some(Self::Aggregate),
56            _ => None,
57        }
58    }
59
60    /// Stable topic name a host maps onto its bus. This crate never subscribes to it.
61    pub fn topic(self) -> &'static str {
62        match self {
63            Self::Ignore => "locus.memory.ignore",
64            Self::Recall => "locus.memory.recall",
65            Self::Find => "locus.memory.find",
66            Self::Persist => "locus.memory.persist",
67            Self::Explain => "locus.memory.explain",
68            Self::Aggregate => "locus.memory.aggregate",
69        }
70    }
71
72    pub fn is_read(self) -> bool {
73        matches!(
74            self,
75            Self::Recall | Self::Find | Self::Explain | Self::Aggregate
76        )
77    }
78
79    pub fn all() -> &'static [Self] {
80        &[
81            Self::Ignore,
82            Self::Recall,
83            Self::Find,
84            Self::Persist,
85            Self::Explain,
86            Self::Aggregate,
87        ]
88    }
89}
90
91/// What the host should do with the envelope.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum MemoryReflexKind {
95    /// Run the attached memory payload.
96    Dispatch,
97    /// Leave the store alone.
98    Ignore,
99    /// Ask a System 2 model before any memory primitive runs.
100    Escalate,
101}
102
103/// Why the gate accepted, dropped, or held the System 1 choice.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum ReflexGate {
107    Accepted,
108    BlankStimulus,
109    BelowSalience,
110    LowConfidence,
111    PropositionDisagreement,
112    System2Required,
113}
114
115/// Calibrated propositions from the `noul` questions.
116#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct MemoryPropositions {
119    pub references_prior: f32,
120    pub should_persist: f32,
121    pub needs_system2: f32,
122}
123
124impl Default for MemoryPropositions {
125    fn default() -> Self {
126        Self {
127            references_prior: 0.0,
128            should_persist: 0.0,
129            needs_system2: 0.0,
130        }
131    }
132}
133
134/// Text the host's persist subscriber should ingest. The reflex does not write a node.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct MemoryPersistHint {
138    pub text: String,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub role: Option<String>,
141}
142
143/// Inbound state. This is the body an event bus would have delivered.
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct MemoryStimulus {
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub id: Option<String>,
149    pub text: String,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub role: Option<String>,
152    #[serde(default)]
153    pub scope: MemoryScope,
154    #[serde(default)]
155    pub metadata: Map<String, Value>,
156}
157
158/// Thresholds that turn a System 1 answer into a dispatch, an ignore, or an escalation.
159#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct ReflexPolicy {
162    /// Choice confidence, and salience confidence, below this escalate.
163    pub min_choice_confidence: f32,
164    /// Normalized salience below this ignores the stimulus.
165    pub min_salience: f32,
166    /// `references_prior` required before a read action can dispatch.
167    pub read_floor: f32,
168    /// `should_persist` required before a persist action can dispatch.
169    pub write_floor: f32,
170    /// `needs_system2` at or above this escalates before any other rule.
171    pub escalate_at: f32,
172    /// Page size copied onto recall and find payloads.
173    pub page_limit: usize,
174}
175
176impl Default for ReflexPolicy {
177    fn default() -> Self {
178        Self {
179            min_choice_confidence: 0.55,
180            min_salience: 0.34,
181            read_floor: 0.45,
182            write_floor: 0.45,
183            escalate_at: 0.70,
184            page_limit: 8,
185        }
186    }
187}
188
189/// Bus envelope for one stimulus.
190///
191/// Runnable payloads are present only when `kind` is [`MemoryReflexKind::Dispatch`].
192/// `companions` names extra operations whose propositions also cleared the floor,
193/// so one message can say "recall, and also persist" without a second publish.
194#[derive(Debug, Clone)]
195pub struct MemoryReflex {
196    pub schema_version: String,
197    pub stimulus_id: String,
198    pub stimulus_text: String,
199    pub role: Option<String>,
200    pub scope: MemoryScope,
201    pub kind: MemoryReflexKind,
202    pub action: MemoryAction,
203    pub topic: String,
204    pub salience: f32,
205    pub salience_label: String,
206    pub salience_confidence: f32,
207    pub confidence: f32,
208    pub propositions: MemoryPropositions,
209    pub gate: ReflexGate,
210    pub companions: Vec<MemoryAction>,
211    pub recall: Option<MemoryRecallRequest>,
212    pub find: Option<MemoryFindRequest>,
213    pub aggregate: Option<MemoryAggregateRequest>,
214    pub persist: Option<MemoryPersistHint>,
215    pub decider_id: String,
216    pub checkpoint: Option<String>,
217    pub metadata: Map<String, Value>,
218}