1use serde::Serialize;
10use serde_json::{Map, Value};
11use std::io;
12
13pub const MAX_OPTIONS: usize = 16;
17
18pub const MAX_SCORE_LEVELS: usize = 10;
20
21pub const CONFIDENCE_METHOD: &str = "one-minus-normalized-entropy";
26
27pub const PROBABILITY_STATUS: &str = "conditional option scores; uncalibrated as decision confidence";
29
30pub const UNVALIDATED_READOUT: &str = "unvalidated";
32
33#[derive(Debug, Clone, PartialEq, Serialize)]
48pub struct ReadoutStatus {
49 pub status: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub evidence: Option<String>,
55}
56
57impl Default for ReadoutStatus {
58 fn default() -> Self {
59 Self {
60 status: UNVALIDATED_READOUT.to_string(),
61 evidence: None,
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct WireError(pub String);
69
70impl std::fmt::Display for WireError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 write!(f, "{}", self.0)
73 }
74}
75
76impl std::error::Error for WireError {}
77
78type Result<T> = std::result::Result<T, WireError>;
79
80fn fail<T>(path: &str, message: &str) -> Result<T> {
81 Err(WireError(format!("{path}: {message}")))
82}
83
84pub fn to_python_json(value: &Value) -> String {
90 let mut buffer = Vec::new();
91 let mut serializer = serde_json::Serializer::with_formatter(&mut buffer, PythonFormatter);
92 value
93 .serialize(&mut serializer)
94 .expect("serializing a serde_json::Value cannot fail");
95 String::from_utf8(buffer).expect("serde_json emits UTF-8")
96}
97
98struct PythonFormatter;
99
100impl serde_json::ser::Formatter for PythonFormatter {
101 fn begin_array_value<W>(&mut self, writer: &mut W, first: bool) -> io::Result<()>
102 where
103 W: ?Sized + io::Write,
104 {
105 if first {
106 Ok(())
107 } else {
108 writer.write_all(b", ")
109 }
110 }
111
112 fn begin_object_key<W>(&mut self, writer: &mut W, first: bool) -> io::Result<()>
113 where
114 W: ?Sized + io::Write,
115 {
116 if first {
117 Ok(())
118 } else {
119 writer.write_all(b", ")
120 }
121 }
122
123 fn begin_object_value<W>(&mut self, writer: &mut W) -> io::Result<()>
124 where
125 W: ?Sized + io::Write,
126 {
127 writer.write_all(b": ")
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Kind {
134 Choice,
136 Noul,
138 Score,
140}
141
142impl Kind {
143 fn parse(value: &str) -> Option<Self> {
144 match value {
145 "choice" => Some(Self::Choice),
146 "noul" => Some(Self::Noul),
147 "score" => Some(Self::Score),
148 _ => None,
149 }
150 }
151}
152
153#[derive(Debug, Clone)]
155pub struct QuestionSpec {
156 pub id: String,
158 pub kind: Kind,
160 pub option_ids: Vec<String>,
162 pub legend: Vec<String>,
164}
165
166#[derive(Debug, Clone, serde::Deserialize)]
168pub struct RowOption {
169 pub id: String,
171 pub description: String,
173}
174
175#[derive(Debug, Clone, serde::Deserialize)]
177pub struct Row {
178 pub id: String,
180 pub state: Value,
182 pub question: String,
184 pub options: Vec<RowOption>,
186}
187
188pub fn request_rows(payload: &Value, served_model: &str) -> Result<(Vec<QuestionSpec>, Vec<Row>)> {
190 let body = match payload {
191 Value::Object(map) => map,
192 _ => return fail("body", "must be a JSON object"),
193 };
194 let state = body.get("state").cloned().unwrap_or(Value::Null);
195 validate_state(&state)?;
196
197 match body.get("model") {
198 Some(Value::String(model)) if model == served_model => {}
199 _ => {
200 return fail(
201 "model",
202 &format!("must be {served_model:?}; this server does not serve Jev aliases"),
203 )
204 }
205 }
206
207 let questions = match body.get("questions") {
208 Some(Value::Object(map)) if !map.is_empty() => map,
209 _ => return fail("questions", "must be a nonempty object"),
210 };
211
212 let mut specs = Vec::with_capacity(questions.len());
213 let mut rows = Vec::with_capacity(questions.len());
214 for (question_id, question) in questions {
215 if question_id.is_empty() {
216 return fail("questions", "question IDs must be nonempty strings");
217 }
218 let base = format!("questions.{question_id}");
219 let question = match question {
220 Value::Object(map) => map,
221 _ => return fail(&base, "must be an object"),
222 };
223 let kind_name = match question.get("type") {
224 Some(Value::String(name)) => name.as_str(),
225 _ => return fail(&format!("{base}.type"), "must be one of: choice, noul, score"),
226 };
227 let kind = match Kind::parse(kind_name) {
228 Some(kind) => kind,
229 None => return fail(&format!("{base}.type"), "must be one of: choice, noul, score"),
230 };
231 let instructions = instructions(question.get("instructions"), kind, &format!("{base}.instructions"))?;
232
233 let (option_ids, options, legend) = match kind {
234 Kind::Choice => choice_options(question, &base)?,
235 Kind::Noul => noul_options(question, &base)?,
236 Kind::Score => score_options(question, &base)?,
237 };
238
239 specs.push(QuestionSpec {
240 id: question_id.clone(),
241 kind,
242 option_ids,
243 legend,
244 });
245 rows.push(Row {
246 id: question_id.clone(),
247 state: state.clone(),
248 question: instructions,
249 options,
250 });
251 }
252 Ok((specs, rows))
253}
254
255fn validate_state(state: &Value) -> Result<()> {
256 match state {
257 Value::String(text) if text.is_empty() => fail("state", "must be a nonempty string, object, or array"),
258 Value::String(_) => Ok(()),
259 Value::Object(map) if map.is_empty() => {
260 fail("state", "must be a nonempty string, object, or array")
261 }
262 Value::Object(_) => Ok(()),
263 Value::Array(items) if items.is_empty() => {
264 fail("state", "must be a nonempty string, object, or array")
265 }
266 Value::Array(_) => Ok(()),
267 _ => fail("state", "must be a nonempty string, object, or array"),
268 }
269}
270
271fn json_text(value: &Value, path: &str) -> Result<String> {
272 match value {
273 Value::String(text) if text.is_empty() => fail(path, "must not be empty"),
274 Value::String(text) => Ok(text.clone()),
275 Value::Object(_) | Value::Array(_) => Ok(to_python_json(value)),
276 _ => fail(path, "must be a string, object, or array"),
277 }
278}
279
280fn description(option_id: &str, value: Option<&Value>, path: &str) -> Result<String> {
281 match value {
282 None | Some(Value::Null) => Ok(option_id.to_string()),
283 Some(value) => Ok(format!("{option_id}: {}", json_text(value, path)?)),
284 }
285}
286
287fn instructions(value: Option<&Value>, kind: Kind, path: &str) -> Result<String> {
288 match value {
289 Some(Value::Null) | None => Ok(match kind {
290 Kind::Choice => "Which option best matches the supplied state?".to_string(),
291 Kind::Noul => "Does the true outcome apply to the supplied state?".to_string(),
292 Kind::Score => "Which ordered level best matches the supplied state?".to_string(),
293 }),
294 Some(value) => json_text(value, path),
295 }
296}
297
298type Options = (Vec<String>, Vec<RowOption>, Vec<String>);
299
300fn choice_options(question: &Map<String, Value>, base: &str) -> Result<Options> {
301 let criteria = match question.get("criteria") {
302 Some(Value::Object(map)) if (2..=MAX_OPTIONS).contains(&map.len()) => map,
303 _ => {
304 return fail(
305 &format!("{base}.criteria"),
306 &format!("must contain 2-{MAX_OPTIONS} options"),
307 )
308 }
309 };
310 let mut option_ids = Vec::with_capacity(criteria.len());
311 let mut options = Vec::with_capacity(criteria.len());
312 for (option_id, value) in criteria {
313 if option_id.is_empty() {
314 return fail(&format!("{base}.criteria"), "option IDs must be nonempty strings");
315 }
316 if !matches!(value, Value::Null | Value::String(_) | Value::Object(_) | Value::Array(_)) {
317 return fail(
318 &format!("{base}.criteria.{option_id}"),
319 "must be a string, object, array, or null",
320 );
321 }
322 option_ids.push(option_id.clone());
323 options.push(RowOption {
324 id: option_id.clone(),
325 description: description(option_id, Some(value), &format!("{base}.criteria.{option_id}"))?,
326 });
327 }
328 Ok((option_ids, options, Vec::new()))
329}
330
331fn noul_options(question: &Map<String, Value>, base: &str) -> Result<Options> {
332 let criteria = match question.get("criteria") {
333 None | Some(Value::Null) => &Map::new(),
334 Some(Value::Object(map)) => map,
335 _ => return fail(&format!("{base}.criteria"), "must be an object when supplied"),
336 };
337 let unknown: Vec<&String> = criteria
338 .keys()
339 .filter(|key| key.as_str() != "true" && key.as_str() != "false")
340 .collect();
341 if !unknown.is_empty() {
342 let mut names: Vec<&str> = unknown.iter().map(|key| key.as_str()).collect();
343 names.sort_unstable();
344 return fail(
345 &format!("{base}.criteria"),
346 &format!("contains unsupported keys: {:?}", names),
347 );
348 }
349 let option_ids = vec!["true".to_string(), "false".to_string()];
350 let defaults = ["The answer is yes.", "The answer is no."];
351 let mut options = Vec::with_capacity(2);
352 for (index, option_id) in option_ids.iter().enumerate() {
353 let path = format!("{base}.criteria.{option_id}");
354 let fallback;
357 let value = match criteria.get(option_id) {
358 None => {
359 fallback = Value::String(defaults[index].to_string());
360 Some(&fallback)
361 }
362 Some(Value::Null) => None,
363 Some(value) => Some(value),
364 };
365 options.push(RowOption {
366 id: option_id.clone(),
367 description: description(option_id, value, &path)?,
368 });
369 }
370 Ok((option_ids, options, Vec::new()))
371}
372
373fn score_options(question: &Map<String, Value>, base: &str) -> Result<Options> {
374 let criteria = match question.get("criteria") {
375 Some(Value::Array(items)) if (2..=MAX_SCORE_LEVELS).contains(&items.len()) => items,
376 _ => {
377 return fail(
378 &format!("{base}.criteria"),
379 &format!("must contain 2-{MAX_SCORE_LEVELS} ordered levels"),
380 )
381 }
382 };
383 let mut option_ids = Vec::with_capacity(criteria.len());
384 let mut options = Vec::with_capacity(criteria.len());
385 let mut legend = Vec::with_capacity(criteria.len());
386 for (index, value) in criteria.iter().enumerate() {
387 let path = format!("{base}.criteria.{index}");
388 let text = json_text(value, &path)?;
389 let option_id = index.to_string();
390 legend.push(text.clone());
391 option_ids.push(option_id.clone());
392 options.push(RowOption {
393 id: option_id.clone(),
394 description: format!("Level {option_id}: {text}"),
395 });
396 }
397 Ok((option_ids, options, legend))
398}
399
400#[derive(Debug, Clone)]
402pub struct ScoredAnswer {
403 pub id: String,
405 pub option_ids: Vec<String>,
407 pub probabilities: Vec<f64>,
409 pub input_tokens: u64,
411 pub prompt_version: Option<String>,
413}
414
415pub fn distribution_confidence(probabilities: &[f64]) -> f64 {
417 let entropy: f64 = probabilities
418 .iter()
419 .filter(|value| **value > 0.0)
420 .map(|value| -value * value.ln())
421 .sum();
422 (1.0 - entropy / (probabilities.len() as f64).ln()).clamp(0.0, 1.0)
423}
424
425fn normalized_probabilities(spec: &QuestionSpec, result: &ScoredAnswer) -> Result<Vec<f64>> {
426 if result.id != spec.id || result.option_ids != spec.option_ids {
427 return Err(WireError(format!(
428 "Scorer result for {:?} does not match its declared options",
429 spec.id
430 )));
431 }
432 if result.probabilities.len() != spec.option_ids.len() {
433 return Err(WireError(format!(
434 "Scorer result for {:?} has an invalid probability vector",
435 spec.id
436 )));
437 }
438 if result
439 .probabilities
440 .iter()
441 .any(|value| !value.is_finite() || *value < 0.0)
442 {
443 return Err(WireError(format!(
444 "Scorer result for {:?} has non-finite or negative probabilities",
445 spec.id
446 )));
447 }
448 let total: f64 = result.probabilities.iter().sum();
449 if total <= 0.0 {
450 return Err(WireError(format!(
451 "Scorer result for {:?} has zero probability mass",
452 spec.id
453 )));
454 }
455 Ok(result.probabilities.iter().map(|value| value / total).collect())
456}
457
458#[derive(Debug)]
464pub struct OrderedMap<V>(Vec<(String, V)>);
465
466impl<V> OrderedMap<V> {
467 pub fn new() -> Self {
469 Self(Vec::new())
470 }
471
472 pub fn insert(&mut self, key: String, value: V) {
474 self.0.push((key, value));
475 }
476
477 pub fn is_empty(&self) -> bool {
479 self.0.is_empty()
480 }
481
482 pub fn len(&self) -> usize {
484 self.0.len()
485 }
486}
487
488impl<V> Default for OrderedMap<V> {
489 fn default() -> Self {
490 Self::new()
491 }
492}
493
494impl<V: Serialize> Serialize for OrderedMap<V> {
495 fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
496 use serde::ser::SerializeMap;
497 let mut map = serializer.serialize_map(Some(self.0.len()))?;
498 for (key, value) in &self.0 {
499 map.serialize_entry(key, value)?;
500 }
501 map.end()
502 }
503}
504
505#[derive(Debug, Serialize)]
512#[serde(tag = "type", rename_all = "lowercase")]
513pub enum Answer {
514 Noul {
516 noul: f64,
518 },
519 Choice {
521 choice: String,
523 probabilities: OrderedMap<f64>,
525 confidence: f64,
527 },
528 Score {
530 score: f64,
532 legend: OrderedMap<String>,
534 probabilities: OrderedMap<f64>,
536 confidence: f64,
538 },
539}
540
541#[derive(Debug, Serialize)]
542pub struct Usage {
544 pub input_tokens: u64,
546 pub output_tokens: u64,
548}
549
550#[derive(Debug, Serialize)]
552pub struct FastjevMeta {
553 pub probability_status: &'static str,
555 pub confidence_method: &'static str,
557 pub prompt_versions: Vec<String>,
559 pub readout: ReadoutStatus,
561}
562
563#[derive(Debug, Serialize)]
565pub struct SystemOneResponse {
566 pub model: String,
568 pub answers: OrderedMap<Answer>,
570 pub usage: Usage,
572 pub fastjev: FastjevMeta,
574}
575
576pub fn response_from_results(
578 served_model: &str,
579 specs: &[QuestionSpec],
580 results: &[ScoredAnswer],
581 readout: &ReadoutStatus,
582) -> Result<SystemOneResponse> {
583 if results.len() != specs.len() {
584 return Err(WireError(
585 "Scorer returned a different number of results than requested".to_string(),
586 ));
587 }
588 let mut answers = OrderedMap::new();
589 let mut input_tokens: u64 = 0;
590 let mut prompt_versions: Vec<String> = Vec::new();
591 for (spec, result) in specs.iter().zip(results) {
592 let probabilities = normalized_probabilities(spec, result)?;
593 let winner_index = probabilities
594 .iter()
595 .enumerate()
596 .fold(0usize, |best, (index, value)| {
597 if *value > probabilities[best] {
598 index
599 } else {
600 best
601 }
602 });
603 let mut distribution = OrderedMap::new();
604 for (option_id, value) in spec.option_ids.iter().zip(&probabilities) {
605 distribution.insert(option_id.clone(), *value);
606 }
607 let answer = match spec.kind {
608 Kind::Noul => {
609 let true_index = spec
610 .option_ids
611 .iter()
612 .position(|option_id| option_id == "true")
613 .ok_or_else(|| {
614 WireError(format!("Scorer result for {:?} lost its true option", spec.id))
615 })?;
616 Answer::Noul {
617 noul: probabilities[true_index],
618 }
619 }
620 Kind::Choice => Answer::Choice {
621 choice: spec.option_ids[winner_index].clone(),
622 probabilities: distribution,
623 confidence: distribution_confidence(&probabilities),
624 },
625 Kind::Score => {
626 let score: f64 = probabilities
627 .iter()
628 .enumerate()
629 .map(|(index, value)| index as f64 * value)
630 .sum();
631 let mut legend: OrderedMap<String> = OrderedMap::new();
632 for (index, text) in spec.legend.iter().enumerate() {
633 legend.insert(index.to_string(), text.clone());
634 }
635 Answer::Score {
636 score,
637 legend,
638 probabilities: distribution,
639 confidence: distribution_confidence(&probabilities),
640 }
641 }
642 };
643 answers.insert(spec.id.clone(), answer);
644 input_tokens += result.input_tokens;
645 if let Some(version) = &result.prompt_version
646 && !prompt_versions.contains(version)
647 {
648 prompt_versions.push(version.clone());
649 }
650 }
651 prompt_versions.sort();
652 Ok(SystemOneResponse {
653 model: served_model.to_string(),
654 answers,
655 usage: Usage {
656 input_tokens,
657 output_tokens: 0,
658 },
659 fastjev: FastjevMeta {
660 probability_status: PROBABILITY_STATUS,
661 confidence_method: CONFIDENCE_METHOD,
662 prompt_versions,
663 readout: readout.clone(),
664 },
665 })
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use serde_json::json;
672
673 fn sample_payload() -> Value {
674 json!({
675 "model": "bridge-model",
676 "state": "Help! My payouts have been failing for three days.",
677 "questions": {
678 "is_urgent": {"type": "noul", "instructions": "Does this convey urgency?"},
679 "department": {
680 "type": "choice",
681 "instructions": "Which team should handle this?",
682 "criteria": {"billing": "Payments, invoicing, and refunds", "technical": null},
683 },
684 "severity": {
685 "type": "score",
686 "instructions": "How severe is the problem?",
687 "criteria": ["Minor", "Degraded", "Blocking"],
688 },
689 },
690 })
691 }
692
693 #[test]
694 fn python_json_layout_matches_cpython_output() {
695 let value = json!({
699 "evidence": {"message": "已扣款两次 charged twice", "count": 2, "tags": ["a", "b"],
700 "ok": true, "missing": null, "ratio": 0.5},
701 "criterion": "Which queue?",
702 "options": [{"letter": "A", "description": "Billing, payments, and refunds."}],
703 });
704 assert_eq!(
705 to_python_json(&value),
706 r#"{"evidence": {"message": "已扣款两次 charged twice", "count": 2, "tags": ["a", "b"], "ok": true, "missing": null, "ratio": 0.5}, "criterion": "Which queue?", "options": [{"letter": "A", "description": "Billing, payments, and refunds."}]}"#
707 );
708 }
709
710 #[test]
711 fn python_json_layout_matches_python_separators() {
712 let value = json!({"a": [1, 2], "b": {"c": "x"}, "d": "中"});
713 assert_eq!(to_python_json(&value), r#"{"a": [1, 2], "b": {"c": "x"}, "d": "中"}"#);
714 }
715
716 #[test]
717 fn rows_render_descriptions_like_fastjev() {
718 let (specs, rows) = request_rows(&sample_payload(), "bridge-model").unwrap();
719 assert_eq!(specs.len(), 3);
720 assert_eq!(rows[1].options[0].description, "billing: Payments, invoicing, and refunds");
721 assert_eq!(rows[1].options[1].description, "technical");
722 assert_eq!(rows[2].options[2].description, "Level 2: Blocking");
723 assert_eq!(rows[0].options[0].description, "true: The answer is yes.");
724 assert_eq!(specs[2].legend, vec!["Minor", "Degraded", "Blocking"]);
725 assert_eq!(rows[1].question, "Which team should handle this?");
726 }
727
728 #[test]
729 fn noul_criteria_defaults_and_nulls_differ() {
730 let payload = json!({
731 "model": "m",
732 "state": "s",
733 "questions": {
734 "defaults": {"type": "noul"},
735 "explicit_null": {"type": "noul", "criteria": {"true": null, "false": "No way."}},
736 },
737 });
738 let (_specs, rows) = request_rows(&payload, "m").unwrap();
739 assert_eq!(rows[0].options[0].description, "true: The answer is yes.");
740 assert_eq!(rows[0].options[1].description, "false: The answer is no.");
741 assert_eq!(rows[1].options[0].description, "true");
742 assert_eq!(rows[1].options[1].description, "false: No way.");
743 }
744
745 #[test]
746 fn model_mismatch_is_rejected() {
747 let error = request_rows(&sample_payload(), "other-model").unwrap_err();
748 assert!(error.0.starts_with("model: "), "{}", error.0);
749 }
750
751 #[test]
752 fn empty_state_is_rejected() {
753 let payload = json!({"model": "m", "state": "", "questions": {"q": {"type": "noul"}}});
754 let error = request_rows(&payload, "m").unwrap_err();
755 assert!(error.0.starts_with("state: "), "{}", error.0);
756 }
757
758 #[test]
759 fn response_uses_probabilities_and_confidence() {
760 let (specs, _rows) = request_rows(&sample_payload(), "bridge-model").unwrap();
761 let results = vec![
762 ScoredAnswer {
763 id: "is_urgent".into(),
764 option_ids: vec!["true".into(), "false".into()],
765 probabilities: vec![0.9, 0.1],
766 input_tokens: 10,
767 prompt_version: Some("direct-options-v1".into()),
768 },
769 ScoredAnswer {
770 id: "department".into(),
771 option_ids: vec!["billing".into(), "technical".into()],
772 probabilities: vec![0.25, 0.75],
773 input_tokens: 20,
774 prompt_version: Some("direct-options-v1".into()),
775 },
776 ScoredAnswer {
777 id: "severity".into(),
778 option_ids: vec!["0".into(), "1".into(), "2".into()],
779 probabilities: vec![0.0, 1.0, 0.0],
780 input_tokens: 30,
781 prompt_version: Some("direct-options-v1".into()),
782 },
783 ];
784 let response = serde_json::to_value(
785 response_from_results("bridge-model", &specs, &results, &ReadoutStatus::default())
786 .unwrap(),
787 )
788 .unwrap();
789 assert_eq!(response["answers"]["is_urgent"]["noul"], 0.9);
790 assert_eq!(response["answers"]["department"]["choice"], "technical");
791 assert_eq!(response["answers"]["severity"]["score"], 1.0);
792 assert_eq!(response["usage"]["input_tokens"], 60);
793 assert_eq!(response["usage"]["output_tokens"], 0);
794 assert_eq!(response["answers"]["severity"]["legend"]["2"], "Blocking");
795 let confidence = response["answers"]["severity"]["confidence"].as_f64().unwrap();
796 assert!((confidence - 1.0).abs() < 1e-12);
797 }
798
799 #[test]
800 fn the_response_carries_the_declared_readout_status() {
801 let (specs, _rows) = request_rows(&sample_payload(), "bridge-model").unwrap();
802 let results = vec![
803 ScoredAnswer {
804 id: "is_urgent".into(),
805 option_ids: vec!["true".into(), "false".into()],
806 probabilities: vec![0.9, 0.1],
807 input_tokens: 10,
808 prompt_version: Some("direct-options-v1".into()),
809 },
810 ScoredAnswer {
811 id: "department".into(),
812 option_ids: vec!["billing".into(), "technical".into()],
813 probabilities: vec![0.25, 0.75],
814 input_tokens: 20,
815 prompt_version: Some("direct-options-v1".into()),
816 },
817 ScoredAnswer {
818 id: "severity".into(),
819 option_ids: vec!["0".into(), "1".into(), "2".into()],
820 probabilities: vec![0.0, 1.0, 0.0],
821 input_tokens: 30,
822 prompt_version: Some("direct-options-v1".into()),
823 },
824 ];
825
826 let response = serde_json::to_value(
828 response_from_results("bridge-model", &specs, &results, &ReadoutStatus::default())
829 .unwrap(),
830 )
831 .unwrap();
832 assert_eq!(response["fastjev"]["readout"]["status"], UNVALIDATED_READOUT);
833 assert!(response["fastjev"]["readout"].get("evidence").is_none());
834
835 let declared = ReadoutStatus {
837 status: "validated".to_string(),
838 evidence: Some("argmax agreement 139/144".to_string()),
839 };
840 let response = serde_json::to_value(
841 response_from_results("bridge-model", &specs, &results, &declared).unwrap(),
842 )
843 .unwrap();
844 assert_eq!(response["fastjev"]["readout"]["status"], "validated");
845 assert_eq!(
846 response["fastjev"]["readout"]["evidence"],
847 "argmax agreement 139/144"
848 );
849 }
850
851 #[test]
852 fn flat_distribution_has_zero_confidence() {
853 assert!(distribution_confidence(&[0.25, 0.25, 0.25, 0.25]).abs() < 1e-12);
854 }
855
856 #[test]
857 fn mismatched_result_is_rejected() {
858 let (specs, _rows) = request_rows(&sample_payload(), "bridge-model").unwrap();
859 let results = vec![ScoredAnswer {
860 id: "is_urgent".into(),
861 option_ids: vec!["false".into(), "true".into()],
862 probabilities: vec![0.5, 0.5],
863 input_tokens: 1,
864 prompt_version: None,
865 }];
866 assert!(
867 response_from_results("bridge-model", &specs, &results, &ReadoutStatus::default())
868 .is_err()
869 );
870 }
871}