1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum DecisionQuestion {
19 Choice {
21 instructions: String,
22 criteria: BTreeMap<String, String>,
23 },
24 Score {
26 instructions: String,
27 criteria: Vec<String>,
28 },
29 Noul { instructions: String },
31}
32
33#[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#[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#[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 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#[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}