Skip to main content

locus_sdk/domain/
system1.rs

1//! System 1 decision contract.
2//!
3//! A System 1 model answers typed `choice`, `score`, and `noul` questions about a
4//! state in one forward pass. Laya, the `sys1` server, and Jev-compatible hosts
5//! all speak this shape at `POST /v1/systemone`. This module is that contract.
6//! It does not load weights and it does not open a socket.
7
8use std::collections::BTreeMap;
9
10use anyhow::{Result, anyhow, bail};
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14
15/// Typed question a System 1 model can answer without generating text.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum DecisionQuestion {
19    /// Pick one label from `criteria` (label → description).
20    Choice {
21        instructions: String,
22        criteria: BTreeMap<String, String>,
23    },
24    /// Place the state on an ordered rubric. Index 0 is the low end.
25    Score {
26        instructions: String,
27        criteria: Vec<String>,
28    },
29    /// Probability that the instruction is true, in `0..=1`.
30    Noul { instructions: String },
31}
32
33/// One answer from a System 1 forward pass.
34#[derive(Debug, Clone, PartialEq)]
35pub enum DecisionAnswer {
36    Choice {
37        choice: String,
38        confidence: f32,
39        probabilities: BTreeMap<String, f32>,
40    },
41    Score {
42        score: f32,
43        max: Option<f32>,
44        label: Option<String>,
45        confidence: f32,
46    },
47    Noul {
48        probability: f32,
49    },
50}
51
52/// Body a host can POST to a System 1 endpoint, or pass to an in-process decider.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct System1Request {
55    pub state: Value,
56    #[serde(default)]
57    pub questions: BTreeMap<String, DecisionQuestion>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub model: Option<String>,
60}
61
62/// Parsed System 1 result. `checkpoint` is the routing model when the server reports one.
63#[derive(Debug, Clone, PartialEq)]
64pub struct System1Response {
65    pub decider_id: String,
66    pub checkpoint: Option<String>,
67    pub answers: BTreeMap<String, DecisionAnswer>,
68}
69
70impl System1Response {
71    /// Parse a Laya / Jev `/v1/systemone` JSON body against the questions that were asked.
72    pub fn parse_wire(
73        questions: &BTreeMap<String, DecisionQuestion>,
74        value: &Value,
75    ) -> Result<Self> {
76        let answers_value = value.get("answers").unwrap_or(value);
77        let answers_obj = answers_value
78            .as_object()
79            .ok_or_else(|| anyhow!("system 1 response is missing an answers object"))?;
80
81        let mut answers = BTreeMap::new();
82        for (name, question) in questions {
83            let raw = answers_obj
84                .get(name)
85                .ok_or_else(|| anyhow!("system 1 response is missing an answer for `{name}`"))?;
86            answers.insert(name.clone(), parse_answer(name, question, raw)?);
87        }
88
89        Ok(Self {
90            decider_id: "wire".to_string(),
91            checkpoint: checkpoint_from(value),
92            answers,
93        })
94    }
95}
96
97/// Something that can run one System 1 forward pass.
98///
99/// Hosts attach a local checkpoint, an HTTP server (`laya-serve`, `sys1`, Jev),
100/// or a test double. The memory reflex does not care which.
101#[async_trait]
102pub trait System1Decider: Send + Sync {
103    fn decider_id(&self) -> &str;
104
105    async fn predict(&self, request: &System1Request) -> Result<System1Response>;
106}
107
108fn checkpoint_from(value: &Value) -> Option<String> {
109    if let Some(model) = value
110        .get("routing")
111        .and_then(|routing| routing.get("model"))
112        .and_then(Value::as_str)
113        .filter(|model| !model.is_empty())
114    {
115        return Some(model.to_string());
116    }
117    value
118        .get("model")
119        .and_then(Value::as_str)
120        .filter(|model| !model.is_empty())
121        .map(ToOwned::to_owned)
122}
123
124fn parse_answer(name: &str, question: &DecisionQuestion, value: &Value) -> Result<DecisionAnswer> {
125    let obj = value
126        .as_object()
127        .ok_or_else(|| anyhow!("system 1 answer `{name}` must be an object"))?;
128    match question {
129        DecisionQuestion::Choice { criteria, .. } => {
130            let raw = obj
131                .get("choice")
132                .and_then(Value::as_str)
133                .ok_or_else(|| anyhow!("system 1 answer `{name}` is missing `choice`"))?;
134            let choice = canonicalize_choice(name, raw, criteria)?;
135            let probabilities = probabilities_from(name, obj)?;
136            let confidence = match obj.get("confidence") {
137                Some(raw) => unit_interval(name, "confidence", as_f32(name, "confidence", raw)?)?,
138                None => probabilities.get(&choice).copied().unwrap_or(0.0),
139            };
140            Ok(DecisionAnswer::Choice {
141                choice,
142                confidence,
143                probabilities,
144            })
145        }
146        DecisionQuestion::Score { .. } => {
147            let score = as_f32(
148                name,
149                "score",
150                obj.get("score")
151                    .ok_or_else(|| anyhow!("system 1 answer `{name}` is missing `score`"))?,
152            )?;
153            if !score.is_finite() || score < 0.0 {
154                bail!(
155                    "system 1 answer `{name}` has a score that is not a non-negative finite number"
156                );
157            }
158            let max = ["max", "out_of", "scale"]
159                .iter()
160                .find_map(|key| obj.get(*key))
161                .map(|raw| as_f32(name, "max", raw))
162                .transpose()?;
163            if let Some(max) = max {
164                if !max.is_finite() || max <= 0.0 {
165                    bail!(
166                        "system 1 answer `{name}` has a max that is not a positive finite number"
167                    );
168                }
169            }
170            let label = obj
171                .get("label")
172                .and_then(Value::as_str)
173                .map(str::trim)
174                .filter(|label| !label.is_empty())
175                .map(ToOwned::to_owned);
176            let confidence = match obj.get("confidence") {
177                Some(raw) => unit_interval(name, "confidence", as_f32(name, "confidence", raw)?)?,
178                None => 0.0,
179            };
180            Ok(DecisionAnswer::Score {
181                score,
182                max,
183                label,
184                confidence,
185            })
186        }
187        DecisionQuestion::Noul { .. } => {
188            let raw = obj
189                .get("noul")
190                .or_else(|| obj.get("probability"))
191                .ok_or_else(|| anyhow!("system 1 answer `{name}` is missing `noul`"))?;
192            let probability = unit_interval(name, "noul", as_f32(name, "noul", raw)?)?;
193            Ok(DecisionAnswer::Noul { probability })
194        }
195    }
196}
197
198fn canonicalize_choice(
199    name: &str,
200    raw: &str,
201    criteria: &BTreeMap<String, String>,
202) -> Result<String> {
203    let trimmed = raw.trim();
204    if trimmed.is_empty() {
205        bail!("system 1 answer `{name}` has an empty choice");
206    }
207    if criteria.is_empty() || criteria.contains_key(trimmed) {
208        return Ok(trimmed.to_string());
209    }
210    if let Some(key) = criteria
211        .keys()
212        .find(|key| key.eq_ignore_ascii_case(trimmed))
213    {
214        return Ok(key.clone());
215    }
216    bail!(
217        "system 1 answer `{name}` returned choice `{trimmed}`, which is outside the question criteria"
218    );
219}
220
221fn probabilities_from(name: &str, obj: &Map<String, Value>) -> Result<BTreeMap<String, f32>> {
222    let Some(raw) = obj.get("probabilities").or_else(|| obj.get("probs")) else {
223        return Ok(BTreeMap::new());
224    };
225    let map = raw.as_object().ok_or_else(|| {
226        anyhow!("system 1 answer `{name}` has probabilities that are not an object")
227    })?;
228    let mut out = BTreeMap::new();
229    for (label, value) in map {
230        let probability =
231            unit_interval(name, "probabilities", as_f32(name, "probabilities", value)?)?;
232        out.insert(label.clone(), probability);
233    }
234    Ok(out)
235}
236
237fn as_f32(name: &str, field: &str, value: &Value) -> Result<f32> {
238    value
239        .as_f64()
240        .map(|number| number as f32)
241        .ok_or_else(|| anyhow!("system 1 answer `{name}` field `{field}` must be a number"))
242}
243
244fn unit_interval(name: &str, field: &str, value: f32) -> Result<f32> {
245    if value.is_finite() && (0.0..=1.0).contains(&value) {
246        Ok(value)
247    } else {
248        bail!("system 1 answer `{name}` field `{field}` must be in 0..=1");
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use std::collections::BTreeMap;
255
256    use serde_json::json;
257
258    use super::{DecisionAnswer, DecisionQuestion, System1Response};
259
260    fn sample_questions() -> BTreeMap<String, DecisionQuestion> {
261        let mut questions = BTreeMap::new();
262        questions.insert(
263            "action".to_string(),
264            DecisionQuestion::Choice {
265                instructions: "which action?".to_string(),
266                criteria: BTreeMap::from([
267                    ("recall".to_string(), "read memory".to_string()),
268                    ("ignore".to_string(), "do nothing".to_string()),
269                ]),
270            },
271        );
272        questions.insert(
273            "salience".to_string(),
274            DecisionQuestion::Score {
275                instructions: "how salient?".to_string(),
276                criteria: vec!["none".to_string(), "relevant".to_string()],
277            },
278        );
279        questions.insert(
280            "needs_system2".to_string(),
281            DecisionQuestion::Noul {
282                instructions: "escalate?".to_string(),
283            },
284        );
285        questions
286    }
287
288    #[test]
289    fn choice_question_matches_laya_wire_shape() {
290        let questions = sample_questions();
291        let value = serde_json::to_value(&questions["action"]).expect("question serializes");
292        assert_eq!(value["type"], "choice");
293        assert_eq!(value["criteria"]["recall"], "read memory");
294
295        let salience = serde_json::to_value(&questions["salience"]).expect("score serializes");
296        assert_eq!(salience["type"], "score");
297        assert_eq!(salience["criteria"][0], "none");
298    }
299
300    #[test]
301    fn parse_wire_reads_laya_answers_and_checkpoint() {
302        let questions = sample_questions();
303        let body = json!({
304            "routing": {"model": "typed-decisions", "reason": "pinned"},
305            "usage": {"input_tokens": 12, "output_tokens": 5},
306            "answers": {
307                "action": {
308                    "choice": "Recall",
309                    "confidence": 0.91,
310                    "probabilities": {"recall": 0.91, "ignore": 0.09}
311                },
312                "salience": {"score": 2.1, "confidence": 0.8},
313                "needs_system2": {"noul": 0.15}
314            }
315        });
316
317        let parsed = System1Response::parse_wire(&questions, &body).expect("wire parses");
318        assert_eq!(parsed.checkpoint.as_deref(), Some("typed-decisions"));
319        match &parsed.answers["action"] {
320            DecisionAnswer::Choice {
321                choice, confidence, ..
322            } => {
323                assert_eq!(choice, "recall");
324                assert!((*confidence - 0.91).abs() < 0.001);
325            }
326            other => panic!("expected choice, got {other:?}"),
327        }
328    }
329
330    #[test]
331    fn parse_wire_rejects_probability_above_one() {
332        let questions = sample_questions();
333        let body = json!({
334            "answers": {
335                "action": {"choice": "ignore", "confidence": 0.9},
336                "salience": {"score": 0.2, "confidence": 0.9},
337                "needs_system2": {"noul": 1.4}
338            }
339        });
340        let error = System1Response::parse_wire(&questions, &body).unwrap_err();
341        assert!(error.to_string().contains("needs_system2"));
342    }
343}