1use std::collections::BTreeMap;
34use std::fmt;
35use std::time::{Duration, Instant};
36
37use serde_json::{json, Map, Value};
38
39pub const DEFAULT_DECIDE_TIMEOUT: Duration = Duration::from_millis(2000);
42
43pub const CHOICE_OPTIONS: std::ops::RangeInclusive<usize> = 2..=255;
45pub const SCORE_LEVELS: std::ops::RangeInclusive<usize> = 2..=10;
47
48const SUM_TOLERANCE: f32 = 1e-2;
53const SUM_SLACK: f32 = 1e-4;
57
58#[derive(Debug, Clone, PartialEq)]
64pub enum DecideError {
65 NotConfigured(String),
68 Provider {
72 provider: String,
73 status: Option<u16>,
74 message: String,
75 retryable: bool,
76 },
77 Malformed(String),
80 Deadline(String),
82 ChainExhausted(Vec<(String, DecideError)>),
85 InvalidQuestion(String),
88 RateLimited {
92 provider: String,
93 retry_after_secs: Option<u64>,
94 },
95 EgressRefused(String),
101}
102
103impl DecideError {
104 pub fn code(&self) -> &'static str {
106 match self {
107 DecideError::NotConfigured(_) => "DEC-E001",
108 DecideError::Provider { .. } => "DEC-E002",
109 DecideError::Malformed(_) => "DEC-E003",
110 DecideError::Deadline(_) => "DEC-E004",
111 DecideError::ChainExhausted(_) => "DEC-E005",
112 DecideError::InvalidQuestion(_) => "DEC-E006",
113 DecideError::RateLimited { .. } => "DEC-E007",
114 DecideError::EgressRefused(_) => "DEC-E008",
115 }
116 }
117
118 pub fn status(&self) -> Option<u16> {
120 match self {
121 DecideError::Provider { status, .. } => *status,
122 _ => None,
123 }
124 }
125
126 pub fn retry_after_secs(&self) -> Option<u64> {
128 match self {
129 DecideError::RateLimited { retry_after_secs, .. } => *retry_after_secs,
130 _ => None,
131 }
132 }
133
134 pub fn stops_chain(&self) -> bool {
141 matches!(
142 self,
143 DecideError::InvalidQuestion(_)
144 | DecideError::Provider { status: Some(400 | 422), .. }
145 | DecideError::EgressRefused(_)
146 )
147 }
148}
149
150impl fmt::Display for DecideError {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 let code = self.code();
153 match self {
154 DecideError::NotConfigured(m) => write!(f, "{code}: decision backend not configured: {m}"),
155 DecideError::Provider { provider, status, message, retryable } => {
156 let st = status.map(|s| format!(" HTTP {s}")).unwrap_or_default();
157 let rt = if *retryable { " (retryable)" } else { "" };
158 write!(f, "{code}: decision provider {provider}{st}{rt}: {message}")
159 }
160 DecideError::Malformed(m) => write!(f, "{code}: malformed decision answer: {m}"),
161 DecideError::Deadline(m) => write!(f, "{code}: decision deadline exceeded: {m}"),
162 DecideError::ChainExhausted(errs) => {
163 write!(f, "{code}: every decision backend failed")?;
164 for (i, (who, e)) in errs.iter().enumerate() {
165 let sep = if i == 0 { ": " } else { "; " };
166 write!(f, "{sep}{who} → {e}")?;
167 }
168 Ok(())
169 }
170 DecideError::InvalidQuestion(m) => write!(f, "{code}: invalid decision question: {m}"),
171 DecideError::RateLimited { provider, retry_after_secs } => match retry_after_secs {
172 Some(s) => write!(f, "{code}: decision provider {provider} rate limited (retry after {s}s)"),
173 None => write!(f, "{code}: decision provider {provider} rate limited"),
174 },
175 DecideError::EgressRefused(m) => write!(
176 f,
177 "{code}: egress pseudonymization failed; the decision request was not sent: {m}"
178 ),
179 }
180 }
181}
182
183impl std::error::Error for DecideError {}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct NoulCriteria {
191 pub yes: String,
192 pub no: String,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum Question {
198 Noul { instructions: String, criteria: Option<NoulCriteria> },
200 Choice { instructions: String, criteria: BTreeMap<String, String> },
202 Score { instructions: String, levels: Vec<String> },
204}
205
206impl Question {
207 pub fn noul(instructions: impl Into<String>) -> Self {
209 Question::Noul { instructions: instructions.into(), criteria: None }
210 }
211
212 pub fn choice<K: Into<String>, D: Into<String>>(
214 instructions: impl Into<String>,
215 options: impl IntoIterator<Item = (K, D)>,
216 ) -> Self {
217 Question::Choice {
218 instructions: instructions.into(),
219 criteria: options.into_iter().map(|(k, d)| (k.into(), d.into())).collect(),
220 }
221 }
222
223 pub fn score<L: Into<String>>(
225 instructions: impl Into<String>,
226 levels: impl IntoIterator<Item = L>,
227 ) -> Self {
228 Question::Score {
229 instructions: instructions.into(),
230 levels: levels.into_iter().map(Into::into).collect(),
231 }
232 }
233
234 pub fn kind(&self) -> &'static str {
236 match self {
237 Question::Noul { .. } => "noul",
238 Question::Choice { .. } => "choice",
239 Question::Score { .. } => "score",
240 }
241 }
242
243 pub fn instructions(&self) -> &str {
244 match self {
245 Question::Noul { instructions, .. }
246 | Question::Choice { instructions, .. }
247 | Question::Score { instructions, .. } => instructions,
248 }
249 }
250
251 pub fn validate(&self, id: &str) -> Result<(), DecideError> {
254 let bad = |m: String| Err(DecideError::InvalidQuestion(format!("{id:?}: {m}")));
255 if self.instructions().trim().is_empty() {
256 return bad("instructions are empty".into());
257 }
258 match self {
259 Question::Noul { .. } => Ok(()),
260 Question::Choice { criteria, .. } => {
261 if !CHOICE_OPTIONS.contains(&criteria.len()) {
262 return bad(format!("a choice needs 2..=255 options, got {}", criteria.len()));
263 }
264 if criteria.keys().any(|k| k.trim().is_empty()) {
265 return bad("a choice option key is empty".into());
266 }
267 Ok(())
268 }
269 Question::Score { levels, .. } => {
270 if !SCORE_LEVELS.contains(&levels.len()) {
271 return bad(format!("a score needs 2..=10 levels, got {}", levels.len()));
272 }
273 Ok(())
274 }
275 }
276 }
277
278 pub fn to_wire(&self) -> Value {
281 match self {
282 Question::Noul { instructions, criteria } => {
283 let mut o = json!({"type": "noul", "instructions": instructions});
284 if let Some(c) = criteria {
285 o["criteria"] = json!({"true": c.yes, "false": c.no});
286 }
287 o
288 }
289 Question::Choice { instructions, criteria } => {
290 json!({"type": "choice", "instructions": instructions, "criteria": criteria})
291 }
292 Question::Score { instructions, levels } => {
293 json!({"type": "score", "instructions": instructions, "criteria": levels})
294 }
295 }
296 }
297
298 pub fn from_wire(id: &str, v: &Value) -> Result<Self, DecideError> {
301 let bad = |m: &str| DecideError::InvalidQuestion(format!("{id:?}: {m}"));
302 let o = v.as_object().ok_or_else(|| bad("a question must be a JSON object"))?;
303 let instructions = o
304 .get("instructions")
305 .and_then(Value::as_str)
306 .ok_or_else(|| bad("missing string `instructions`"))?
307 .to_string();
308 let criteria = o.get("criteria").filter(|c| !c.is_null());
309 let q = match o.get("type").and_then(Value::as_str) {
310 Some("noul") => {
311 let criteria = match criteria {
312 None => None,
313 Some(c) => {
314 let c = c.as_object().ok_or_else(|| bad("noul `criteria` must be an object"))?;
315 let get = |a: &str, b: &str| {
316 c.get(a).or_else(|| c.get(b)).and_then(Value::as_str).map(str::to_string)
317 };
318 Some(NoulCriteria {
319 yes: get("true", "yes").ok_or_else(|| bad("noul `criteria` needs a \"true\" string"))?,
320 no: get("false", "no").ok_or_else(|| bad("noul `criteria` needs a \"false\" string"))?,
321 })
322 }
323 };
324 Question::Noul { instructions, criteria }
325 }
326 Some("choice") => {
327 let c = criteria
328 .and_then(Value::as_object)
329 .ok_or_else(|| bad("choice `criteria` must be an object of option → description"))?;
330 let mut criteria = BTreeMap::new();
331 for (k, d) in c {
332 let d = d.as_str().ok_or_else(|| bad("choice option descriptions must be strings"))?;
333 criteria.insert(k.clone(), d.to_string());
334 }
335 Question::Choice { instructions, criteria }
336 }
337 Some("score") => {
338 let c = criteria
339 .and_then(Value::as_array)
340 .ok_or_else(|| bad("score `criteria` must be an array of levels, lowest first"))?;
341 let mut levels = Vec::with_capacity(c.len());
342 for l in c {
343 levels.push(l.as_str().ok_or_else(|| bad("score levels must be strings"))?.to_string());
344 }
345 Question::Score { instructions, levels }
346 }
347 Some(other) => return Err(bad(&format!("unknown question type {other:?} (noul|choice|score)"))),
348 None => return Err(bad("missing `type` (noul|choice|score)")),
349 };
350 q.validate(id)?;
351 Ok(q)
352 }
353}
354
355pub fn questions_from_wire(v: &Value) -> Result<BTreeMap<String, Question>, DecideError> {
358 let o = v
359 .as_object()
360 .ok_or_else(|| DecideError::InvalidQuestion("`questions` must be a JSON object of id → question".into()))?;
361 let mut out = BTreeMap::new();
362 for (id, q) in o {
363 out.insert(id.clone(), Question::from_wire(id, q)?);
364 }
365 if out.is_empty() {
366 return Err(DecideError::InvalidQuestion("no questions".into()));
367 }
368 Ok(out)
369}
370
371pub fn questions_to_wire(questions: &BTreeMap<String, Question>) -> Value {
373 Value::Object(questions.iter().map(|(id, q)| (id.clone(), q.to_wire())).collect())
374}
375
376#[derive(Debug, Clone, PartialEq)]
380pub enum Answer {
381 Noul { p: f32 },
383 Choice { choice: String, probabilities: BTreeMap<String, f32>, confidence: f32 },
384 Score {
388 score: f32,
389 probabilities: BTreeMap<String, f32>,
390 confidence: f32,
391 legend: BTreeMap<String, String>,
392 },
393}
394
395impl Answer {
396 pub fn kind(&self) -> &'static str {
398 match self {
399 Answer::Noul { .. } => "noul",
400 Answer::Choice { .. } => "choice",
401 Answer::Score { .. } => "score",
402 }
403 }
404
405 pub fn to_wire(&self) -> Value {
407 let probs = |m: &BTreeMap<String, f32>| {
408 Value::Object(m.iter().map(|(k, p)| (k.clone(), num(*p))).collect())
409 };
410 match self {
411 Answer::Noul { p } => json!({"type": "noul", "noul": num(*p)}),
412 Answer::Choice { choice, probabilities, confidence } => json!({
413 "type": "choice", "choice": choice,
414 "probabilities": probs(probabilities), "confidence": num(*confidence),
415 }),
416 Answer::Score { score, probabilities, confidence, legend } => json!({
417 "type": "score", "score": num(*score),
418 "probabilities": probs(probabilities), "legend": legend,
419 "confidence": num(*confidence),
420 }),
421 }
422 }
423}
424
425pub fn confidence_from(p_max: f32, n: usize) -> f32 {
428 if n < 2 {
429 return 1.0;
430 }
431 let n = n as f32;
432 ((n * p_max - 1.0) / (n - 1.0)).clamp(0.0, 1.0)
433}
434
435fn num(x: f32) -> Value {
439 format!("{x}")
440 .parse::<f64>()
441 .ok()
442 .and_then(serde_json::Number::from_f64)
443 .map(Value::Number)
444 .unwrap_or(Value::Null)
445}
446
447fn as_f32(v: &Value) -> Option<f32> {
448 v.as_f64().map(|x| x as f32).filter(|x| x.is_finite())
449}
450
451#[derive(Clone, Copy, PartialEq, Eq)]
455enum Mode {
456 Strict,
457 Lenient,
458}
459
460pub fn parse_wire_answers(
468 questions: &BTreeMap<String, Question>,
469 answers: &Value,
470) -> Result<BTreeMap<String, Answer>, DecideError> {
471 parse_answers(questions, answers, Mode::Strict)
472}
473
474pub fn parse_emulated_answers(
481 questions: &BTreeMap<String, Question>,
482 answers: &Value,
483) -> Result<BTreeMap<String, Answer>, DecideError> {
484 parse_answers(questions, answers, Mode::Lenient)
485}
486
487fn parse_answers(
488 questions: &BTreeMap<String, Question>,
489 answers: &Value,
490 mode: Mode,
491) -> Result<BTreeMap<String, Answer>, DecideError> {
492 let o = answers
493 .as_object()
494 .ok_or_else(|| DecideError::Malformed("`answers` is not a JSON object".into()))?;
495 let mut out = BTreeMap::new();
496 for (id, q) in questions {
497 let a = o
498 .get(id)
499 .ok_or_else(|| DecideError::Malformed(format!("no answer for question {id:?}")))?;
500 out.insert(id.clone(), parse_answer(id, q, a, mode)?);
501 }
502 Ok(out)
503}
504
505fn parse_answer(id: &str, q: &Question, a: &Value, mode: Mode) -> Result<Answer, DecideError> {
506 let bad = |m: String| DecideError::Malformed(format!("{id:?}: {m}"));
507 if let (Mode::Lenient, Question::Noul { .. }, Some(p)) = (mode, q, as_f32(a)) {
509 return noul_p(p).map(|p| Answer::Noul { p }).map_err(bad);
510 }
511 let o = a.as_object().ok_or_else(|| bad("an answer must be a JSON object".into()))?;
512 match o.get("type").and_then(Value::as_str) {
513 Some(t) if t == q.kind() => {}
514 Some(t) if matches!(t, "noul" | "choice" | "score") => {
515 return Err(bad(format!("answered as {t:?} but asked as {:?}", q.kind())))
516 }
517 Some(t) => return Err(bad(format!("unknown answer type {t:?}"))),
518 None if o.contains_key("type") => return Err(bad("`type` is not a string".into())),
519 None => {} }
521 match q {
522 Question::Noul { .. } => {
523 let p = ["noul", "p", "probability"]
524 .iter()
525 .find_map(|k| o.get(*k))
526 .ok_or_else(|| bad("noul answer has no `noul` probability".into()))?;
527 let p = as_f32(p).ok_or_else(|| bad("noul probability is not a finite number".into()))?;
528 noul_p(p).map(|p| Answer::Noul { p }).map_err(bad)
529 }
530 Question::Choice { criteria, .. } => {
531 let mut dist: BTreeMap<String, f32> = criteria.keys().map(|k| (k.clone(), 0.0)).collect();
532 let named = o.get("choice").and_then(Value::as_str).map(str::to_string);
533 match o.get("probabilities").and_then(Value::as_object) {
534 Some(ps) => {
535 for (k, p) in ps {
536 let slot = dist
537 .get_mut(k)
538 .ok_or_else(|| bad(format!("probability for unknown option {k:?}")))?;
539 *slot = as_f32(p).ok_or_else(|| bad(format!("probability for {k:?} is not a finite number")))?;
540 }
541 }
542 None => match (mode, &named) {
543 (Mode::Lenient, Some(c)) if dist.contains_key(c) => {
545 dist.insert(c.clone(), 1.0);
546 }
547 _ => return Err(bad("choice answer has no `probabilities`".into())),
548 },
549 }
550 normalize(&mut dist, mode).map_err(bad)?;
551 let (arg, p_max) = argmax(&dist);
552 let choice = match named {
553 Some(c) if dist.contains_key(&c) => c,
554 Some(c) if mode == Mode::Strict => return Err(bad(format!("choice {c:?} is not an option"))),
555 _ => arg,
556 };
557 let confidence = provided_confidence(o, mode)
558 .map_err(bad)?
559 .unwrap_or_else(|| confidence_from(p_max, criteria.len()));
560 Ok(Answer::Choice { choice, probabilities: dist, confidence })
561 }
562 Question::Score { levels, .. } => {
563 let n = levels.len();
564 let mut dist: BTreeMap<String, f32> = (0..n).map(|i| (i.to_string(), 0.0)).collect();
565 match o.get("probabilities").and_then(Value::as_object) {
566 Some(ps) => {
567 for (k, p) in ps {
568 let idx = k
569 .parse::<usize>()
570 .ok()
571 .filter(|i| *i < n)
572 .or_else(|| match mode {
573 Mode::Lenient => levels.iter().position(|l| l == k),
574 Mode::Strict => None,
575 })
576 .ok_or_else(|| bad(format!("probability for unknown level {k:?}")))?;
577 let p = as_f32(p).ok_or_else(|| bad(format!("probability for {k:?} is not a finite number")))?;
578 dist.insert(idx.to_string(), p);
579 }
580 }
581 None => match (mode, o.get("score").and_then(as_f32)) {
582 (Mode::Lenient, Some(s)) if s >= 0.0 && s <= (n - 1) as f32 => {
583 dist.insert((s.round() as usize).to_string(), 1.0);
584 }
585 _ => return Err(bad("score answer has no `probabilities`".into())),
586 },
587 }
588 normalize(&mut dist, mode).map_err(bad)?;
589 let weighted: f32 = dist.iter().map(|(k, p)| k.parse::<f32>().unwrap_or(0.0) * p).sum();
590 let score = match (mode, o.get("score").and_then(as_f32)) {
591 (Mode::Strict, Some(s)) if s >= 0.0 && s <= (n - 1) as f32 => s,
592 (Mode::Strict, Some(s)) => return Err(bad(format!("score {s} is outside 0..={}", n - 1))),
593 _ => weighted,
594 };
595 let (_, p_max) = argmax(&dist);
596 let confidence = provided_confidence(o, mode)
597 .map_err(bad)?
598 .unwrap_or_else(|| confidence_from(p_max, n));
599 let legend = o
600 .get("legend")
601 .and_then(Value::as_object)
602 .filter(|_| mode == Mode::Strict)
603 .map(|l| {
604 l.iter()
605 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
606 .collect::<BTreeMap<_, _>>()
607 })
608 .filter(|l| l.len() == n)
609 .unwrap_or_else(|| levels.iter().enumerate().map(|(i, l)| (i.to_string(), l.clone())).collect());
610 Ok(Answer::Score { score, probabilities: dist, confidence, legend })
611 }
612 }
613}
614
615fn noul_p(p: f32) -> Result<f32, String> {
616 if (-SUM_TOLERANCE..=1.0 + SUM_TOLERANCE).contains(&p) {
617 Ok(p.clamp(0.0, 1.0))
618 } else {
619 Err(format!("noul probability {p} is outside 0..=1"))
620 }
621}
622
623fn normalize(dist: &mut BTreeMap<String, f32>, mode: Mode) -> Result<(), String> {
626 if let Some((k, p)) = dist.iter().find(|(_, p)| **p < 0.0) {
627 return Err(format!("negative probability {p} for {k:?}"));
628 }
629 let sum: f32 = dist.values().sum();
630 let ok = match mode {
631 Mode::Strict => (sum - 1.0).abs() <= SUM_TOLERANCE + SUM_SLACK,
632 Mode::Lenient => sum > 0.0,
633 };
634 if !ok {
635 return Err(format!("probabilities sum to {sum}, not 1"));
636 }
637 for p in dist.values_mut() {
638 *p /= sum;
639 }
640 Ok(())
641}
642
643fn argmax(dist: &BTreeMap<String, f32>) -> (String, f32) {
645 let mut best = (String::new(), f32::NEG_INFINITY);
646 for (k, p) in dist {
647 if *p > best.1 {
648 best = (k.clone(), *p);
649 }
650 }
651 best
652}
653
654fn provided_confidence(o: &Map<String, Value>, mode: Mode) -> Result<Option<f32>, String> {
657 if mode == Mode::Lenient {
658 return Ok(None);
659 }
660 match o.get("confidence") {
661 None | Some(Value::Null) => Ok(None),
662 Some(v) => as_f32(v)
663 .map(|c| Some(c.clamp(0.0, 1.0)))
664 .ok_or_else(|| "confidence is not a finite number".to_string()),
665 }
666}
667
668#[derive(Debug, Clone, PartialEq)]
672pub struct DecideRequest {
673 pub state: Value,
675 pub questions: BTreeMap<String, Question>,
676 pub deadline: Option<Duration>,
678}
679
680impl DecideRequest {
681 pub fn new(state: impl Into<Value>, questions: BTreeMap<String, Question>) -> Self {
682 DecideRequest { state: state.into(), questions, deadline: None }
683 }
684
685 pub fn with_deadline(mut self, deadline: Option<Duration>) -> Self {
686 self.deadline = deadline;
687 self
688 }
689
690 pub fn validate(&self) -> Result<(), DecideError> {
693 if self.questions.is_empty() {
694 return Err(DecideError::InvalidQuestion("no questions".into()));
695 }
696 if !(self.state.is_string() || self.state.is_object() || self.state.is_array()) {
697 return Err(DecideError::InvalidQuestion(
698 "`state` must be a string, object or array".into(),
699 ));
700 }
701 for (id, q) in &self.questions {
702 if id.trim().is_empty() {
703 return Err(DecideError::InvalidQuestion("a question id is empty".into()));
704 }
705 q.validate(id)?;
706 }
707 Ok(())
708 }
709
710 pub fn to_wire(&self, model: Option<&str>) -> Value {
713 let mut o = Map::new();
714 if let Some(m) = model {
715 o.insert("model".into(), Value::String(m.to_string()));
716 }
717 o.insert("state".into(), self.state.clone());
718 o.insert("questions".into(), questions_to_wire(&self.questions));
719 Value::Object(o)
720 }
721}
722
723#[derive(Debug, Clone, PartialEq)]
725pub struct Decision {
726 pub answers: BTreeMap<String, Answer>,
727 pub model: String,
729 pub provider: String,
731 pub calibrated: bool,
732 pub input_tokens: Option<u64>,
733 pub output_tokens: Option<u64>,
734 pub usd_micros: Option<u64>,
739 pub latency_ms: u64,
740}
741
742impl Decision {
743 pub fn to_json(&self) -> Value {
747 let mut o = json!({
748 "model": self.model,
749 "answers": Value::Object(self.answers.iter().map(|(k, a)| (k.clone(), a.to_wire())).collect()),
750 "provider": self.provider,
751 "calibrated": self.calibrated,
752 "latency_ms": self.latency_ms,
753 });
754 if self.input_tokens.is_some() || self.output_tokens.is_some() || self.usd_micros.is_some() {
755 o["usage"] = json!({"input_tokens": self.input_tokens, "output_tokens": self.output_tokens});
756 if let Some(n) = self.usd_micros {
757 o["usage"]["usd_micros"] = json!(n);
758 }
759 }
760 o
761 }
762
763 pub fn from_wire(
769 req: &DecideRequest,
770 body: &Value,
771 provider: &str,
772 default_model: &str,
773 calibrated: bool,
774 started: Instant,
775 ) -> Result<Decision, DecideError> {
776 let answers = body
777 .get("answers")
778 .ok_or_else(|| DecideError::Malformed("response has no `answers`".into()))?;
779 let answers = parse_answers(&req.questions, answers, Mode::Strict)?;
780 let tokens = |k: &str| body.get("usage").and_then(|u| u.get(k)).and_then(Value::as_u64);
781 Ok(Decision {
782 answers,
783 model: body
784 .get("model")
785 .and_then(Value::as_str)
786 .filter(|m| !m.is_empty())
787 .unwrap_or(default_model)
788 .to_string(),
789 provider: provider.to_string(),
790 calibrated,
791 input_tokens: tokens("input_tokens"),
792 output_tokens: tokens("output_tokens"),
793 usd_micros: body
794 .get("usage")
795 .and_then(|u| u.get("cost"))
796 .and_then(Value::as_f64)
797 .and_then(usd_to_micros),
798 latency_ms: elapsed_ms(started),
799 })
800 }
801}
802
803fn usd_to_micros(usd: f64) -> Option<u64> {
806 let micros = (usd * 1_000_000.0).ceil();
807 (usd.is_finite() && usd >= 0.0 && micros < u64::MAX as f64).then_some(micros as u64)
808}
809
810fn elapsed_ms(started: Instant) -> u64 {
811 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
812}
813
814pub trait DecisionBackend: Send + Sync {
817 fn decide(&self, req: &DecideRequest) -> Result<Decision, DecideError>;
818 fn calibrated(&self) -> bool;
822 fn describe(&self) -> String;
824}
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 fn qs(pairs: Vec<(&str, Question)>) -> BTreeMap<String, Question> {
830 pairs.into_iter().map(|(k, q)| (k.to_string(), q)).collect()
831 }
832
833 #[test]
834 fn provider_cost_is_micro_dollars_rounded_up_and_never_guessed() {
835 assert_eq!(usd_to_micros(0.00001575), Some(16));
836 assert_eq!(usd_to_micros(0.0), Some(0));
837 assert_eq!(usd_to_micros(1.5), Some(1_500_000));
838 assert_eq!(usd_to_micros(-0.01), None);
839 assert_eq!(usd_to_micros(f64::NAN), None);
840 assert_eq!(usd_to_micros(f64::INFINITY), None);
841 let req = DecideRequest::new("s", qs(vec![("ok", Question::noul("ok?"))]));
842 let body = json!({"answers": {"ok": {"type": "noul", "noul": 0.9}}, "usage": {"input_tokens": 3}});
843 let d = Decision::from_wire(&req, &body, "p", "m", true, Instant::now()).unwrap();
844 assert_eq!(d.usd_micros, None, "no reported cost is no cost, not an estimate");
845 assert!(d.to_json()["usage"].get("usd_micros").is_none());
846 }
847
848 #[test]
849 fn codes_are_unique_well_formed_and_lead_display() {
850 let all = [
851 DecideError::NotConfigured(String::new()),
852 DecideError::Provider { provider: String::new(), status: None, message: String::new(), retryable: false },
853 DecideError::Malformed(String::new()),
854 DecideError::Deadline(String::new()),
855 DecideError::ChainExhausted(Vec::new()),
856 DecideError::InvalidQuestion(String::new()),
857 DecideError::RateLimited { provider: String::new(), retry_after_secs: None },
858 DecideError::EgressRefused(String::new()),
859 ];
860 let mut seen = std::collections::BTreeSet::new();
861 for e in &all {
862 let c = e.code();
863 assert!(c.starts_with("DEC-E") && c.len() == 8, "bad code {c}");
864 assert!(seen.insert(c), "duplicate code {c}");
865 assert!(e.to_string().starts_with(&format!("{c}: ")), "{e}");
866 }
867 assert_eq!(seen.len(), 8);
868 }
869
870 #[test]
871 fn confidence_formula() {
872 assert_eq!(confidence_from(0.5, 2), 0.0);
873 assert_eq!(confidence_from(1.0, 4), 1.0);
874 assert!((confidence_from(0.8, 2) - 0.6).abs() < 1e-6);
875 assert!((confidence_from(0.5, 3) - 0.25).abs() < 1e-6);
876 }
877
878 #[test]
879 fn question_bounds_are_enforced() {
880 let one = Question::choice("pick", [("a", "A")]);
881 assert_eq!(one.validate("q").unwrap_err().code(), "DEC-E006");
882 let many = Question::Choice {
883 instructions: "pick".into(),
884 criteria: (0..256).map(|i| (format!("o{i}"), String::new())).collect(),
885 };
886 assert_eq!(many.validate("q").unwrap_err().code(), "DEC-E006");
887 let max = Question::Choice {
888 instructions: "pick".into(),
889 criteria: (0..255).map(|i| (format!("o{i}"), String::new())).collect(),
890 };
891 assert!(max.validate("q").is_ok());
892 assert!(Question::score("s", ["lo"]).validate("q").is_err());
893 assert!(Question::score("s", (0..11).map(|i| i.to_string())).validate("q").is_err());
894 assert!(Question::score("s", (0..10).map(|i| i.to_string())).validate("q").is_ok());
895 assert!(Question::noul(" ").validate("q").is_err());
896 let empty = DecideRequest::new("s", BTreeMap::new());
897 assert_eq!(empty.validate().unwrap_err().code(), "DEC-E006");
898 let numeric_state = DecideRequest::new(json!(3), qs(vec![("a", Question::noul("x"))]));
899 assert_eq!(numeric_state.validate().unwrap_err().code(), "DEC-E006");
900 }
901
902 #[test]
903 fn question_wire_roundtrips() {
904 let q = qs(vec![
905 ("n", Question::Noul {
906 instructions: "is it?".into(),
907 criteria: Some(NoulCriteria { yes: "it is".into(), no: "it is not".into() }),
908 }),
909 ("c", Question::choice("which", [("a", "A"), ("b", "B")])),
910 ("s", Question::score("how much", ["low", "mid", "high"])),
911 ]);
912 let wire = questions_to_wire(&q);
913 assert_eq!(wire["n"]["criteria"], json!({"true": "it is", "false": "it is not"}));
914 assert_eq!(wire["s"]["criteria"], json!(["low", "mid", "high"]));
915 assert_eq!(questions_from_wire(&wire).unwrap(), q);
916 let bad = json!({"x": {"type": "rank", "instructions": "?"}});
917 assert_eq!(questions_from_wire(&bad).unwrap_err().code(), "DEC-E006");
918 }
919
920 #[test]
921 fn strict_parse_normalizes_within_tolerance_and_refuses_beyond() {
922 let q = qs(vec![("c", Question::choice("w", [("a", ""), ("b", "")]))]);
923 let near = json!({"c": {"type": "choice", "choice": "a", "probabilities": {"a": 0.8, "b": 0.2005}}});
924 let a = parse_wire_answers(&q, &near).unwrap();
925 let Answer::Choice { probabilities, confidence, .. } = &a["c"] else { panic!() };
926 assert!((probabilities.values().sum::<f32>() - 1.0).abs() < 1e-6);
927 assert!((confidence - 0.6).abs() < 1e-3, "computed when omitted: {confidence}");
928 let rounded = json!({"c": {"type": "choice", "probabilities": {"a": 0.8, "b": 0.19}}});
929 assert!(parse_wire_answers(&q, &rounded).is_ok(), "two-place rounding (sum 0.99) normalizes");
930 let far = json!({"c": {"type": "choice", "choice": "a", "probabilities": {"a": 1.0, "b": 1.0}}});
931 assert_eq!(parse_wire_answers(&q, &far).unwrap_err().code(), "DEC-E003");
932 let mismatch = json!({"c": {"type": "noul", "noul": 0.5}});
933 assert_eq!(parse_wire_answers(&q, &mismatch).unwrap_err().code(), "DEC-E003");
934 let unknown = json!({"c": {"type": "rank"}});
935 assert_eq!(parse_wire_answers(&q, &unknown).unwrap_err().code(), "DEC-E003");
936 let no_probs = json!({"c": {"type": "choice", "choice": "a"}});
937 assert_eq!(parse_wire_answers(&q, &no_probs).unwrap_err().code(), "DEC-E003");
938 let stray = json!({"c": {"type": "choice", "probabilities": {"a": 0.5, "z": 0.5}}});
939 assert_eq!(parse_wire_answers(&q, &stray).unwrap_err().code(), "DEC-E003");
940 }
941
942 #[test]
947 fn two_place_rounding_at_the_tolerance_edge_normalizes() {
948 let q = qs(vec![("s", Question::score("s", ["a", "b", "c", "d"]))]);
949 for target in [99u32, 101] {
950 for a in 0..=target.min(100) {
951 for b in 0..=(target - a).min(100) {
952 let rest = target - a - b;
953 let (c, d) = (rest / 2, rest - rest / 2);
954 if c > 100 || d > 100 {
955 continue;
956 }
957 let p = |x: u32| x as f64 / 100.0;
958 let ans = json!({"s": {"type": "score", "probabilities":
959 {"0": p(a), "1": p(b), "2": p(c), "3": p(d)}}});
960 assert!(
961 parse_wire_answers(&q, &ans).is_ok(),
962 "{a}+{b}+{c}+{d} = {target}/100 must normalize"
963 );
964 }
965 }
966 }
967 let far = json!({"s": {"type": "score", "probabilities": {"0": 0.5, "1": 0.2, "2": 0.2, "3": 0.08}}});
969 assert_eq!(parse_wire_answers(&q, &far).unwrap_err().code(), "DEC-E003");
970 }
971
972 #[test]
973 fn floats_serialize_by_shortest_form() {
974 assert_eq!(num(0.8).to_string(), "0.8");
975 assert_eq!(Answer::Noul { p: 0.93 }.to_wire(), json!({"type": "noul", "noul": 0.93}));
976 }
977}