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
188impl Row {
189 pub fn into_parts(self) -> (Value, Question) {
195 (
196 self.state,
197 Question {
198 id: self.id,
199 question: self.question,
200 options: self.options,
201 },
202 )
203 }
204}
205
206#[derive(Debug, Clone, serde::Deserialize)]
211pub struct Question {
212 pub id: String,
214 pub question: String,
216 pub options: Vec<RowOption>,
218}
219
220#[derive(Debug)]
223pub struct RequestBatch {
224 pub specs: Vec<QuestionSpec>,
226 pub state: Value,
229 pub questions: Vec<Question>,
231}
232
233pub fn request_batch(payload: &Value, served_model: &str) -> Result<RequestBatch> {
236 let body = match payload {
237 Value::Object(map) => map,
238 _ => return fail("body", "must be a JSON object"),
239 };
240 let state = body.get("state").cloned().unwrap_or(Value::Null);
241 validate_state(&state)?;
242
243 match body.get("model") {
244 Some(Value::String(model)) if model == served_model => {}
245 _ => {
246 return fail(
247 "model",
248 &format!("must be {served_model:?}; this server does not serve Jev aliases"),
249 )
250 }
251 }
252
253 let questions = match body.get("questions") {
254 Some(Value::Object(map)) if !map.is_empty() => map,
255 _ => return fail("questions", "must be a nonempty object"),
256 };
257
258 let mut specs = Vec::with_capacity(questions.len());
259 let mut converted = Vec::with_capacity(questions.len());
260 for (question_id, question) in questions {
261 if question_id.is_empty() {
262 return fail("questions", "question IDs must be nonempty strings");
263 }
264 let base = format!("questions.{question_id}");
265 let question = match question {
266 Value::Object(map) => map,
267 _ => return fail(&base, "must be an object"),
268 };
269 let kind_name = match question.get("type") {
270 Some(Value::String(name)) => name.as_str(),
271 _ => return fail(&format!("{base}.type"), "must be one of: choice, noul, score"),
272 };
273 let kind = match Kind::parse(kind_name) {
274 Some(kind) => kind,
275 None => return fail(&format!("{base}.type"), "must be one of: choice, noul, score"),
276 };
277 let instructions = instructions(question.get("instructions"), kind, &format!("{base}.instructions"))?;
278
279 let (option_ids, options, legend) = match kind {
280 Kind::Choice => choice_options(question, &base)?,
281 Kind::Noul => noul_options(question, &base)?,
282 Kind::Score => score_options(question, &base)?,
283 };
284
285 specs.push(QuestionSpec {
286 id: question_id.clone(),
287 kind,
288 option_ids,
289 legend,
290 });
291 converted.push(Question {
292 id: question_id.clone(),
293 question: instructions,
294 options,
295 });
296 }
297 Ok(RequestBatch {
298 specs,
299 state,
300 questions: converted,
301 })
302}
303
304fn validate_state(state: &Value) -> Result<()> {
305 match state {
306 Value::String(text) if text.is_empty() => fail("state", "must be a nonempty string, object, or array"),
307 Value::String(_) => Ok(()),
308 Value::Object(map) if map.is_empty() => {
309 fail("state", "must be a nonempty string, object, or array")
310 }
311 Value::Object(_) => Ok(()),
312 Value::Array(items) if items.is_empty() => {
313 fail("state", "must be a nonempty string, object, or array")
314 }
315 Value::Array(_) => Ok(()),
316 _ => fail("state", "must be a nonempty string, object, or array"),
317 }
318}
319
320fn json_text(value: &Value, path: &str) -> Result<String> {
321 match value {
322 Value::String(text) if text.is_empty() => fail(path, "must not be empty"),
323 Value::String(text) => Ok(text.clone()),
324 Value::Object(_) | Value::Array(_) => Ok(to_python_json(value)),
325 _ => fail(path, "must be a string, object, or array"),
326 }
327}
328
329fn description(option_id: &str, value: Option<&Value>, path: &str) -> Result<String> {
330 match value {
331 None | Some(Value::Null) => Ok(option_id.to_string()),
332 Some(value) => Ok(format!("{option_id}: {}", json_text(value, path)?)),
333 }
334}
335
336fn instructions(value: Option<&Value>, kind: Kind, path: &str) -> Result<String> {
337 match value {
338 Some(Value::Null) | None => Ok(match kind {
339 Kind::Choice => "Which option best matches the supplied state?".to_string(),
340 Kind::Noul => "Does the true outcome apply to the supplied state?".to_string(),
341 Kind::Score => "Which ordered level best matches the supplied state?".to_string(),
342 }),
343 Some(value) => json_text(value, path),
344 }
345}
346
347type Options = (Vec<String>, Vec<RowOption>, Vec<String>);
348
349fn choice_options(question: &Map<String, Value>, base: &str) -> Result<Options> {
350 let criteria = match question.get("criteria") {
351 Some(Value::Object(map)) if (2..=MAX_OPTIONS).contains(&map.len()) => map,
352 _ => {
353 return fail(
354 &format!("{base}.criteria"),
355 &format!("must contain 2-{MAX_OPTIONS} options"),
356 )
357 }
358 };
359 let mut option_ids = Vec::with_capacity(criteria.len());
360 let mut options = Vec::with_capacity(criteria.len());
361 for (option_id, value) in criteria {
362 if option_id.is_empty() {
363 return fail(&format!("{base}.criteria"), "option IDs must be nonempty strings");
364 }
365 if !matches!(value, Value::Null | Value::String(_) | Value::Object(_) | Value::Array(_)) {
366 return fail(
367 &format!("{base}.criteria.{option_id}"),
368 "must be a string, object, array, or null",
369 );
370 }
371 option_ids.push(option_id.clone());
372 options.push(RowOption {
373 id: option_id.clone(),
374 description: description(option_id, Some(value), &format!("{base}.criteria.{option_id}"))?,
375 });
376 }
377 Ok((option_ids, options, Vec::new()))
378}
379
380fn noul_options(question: &Map<String, Value>, base: &str) -> Result<Options> {
381 let criteria = match question.get("criteria") {
382 None | Some(Value::Null) => &Map::new(),
383 Some(Value::Object(map)) => map,
384 _ => return fail(&format!("{base}.criteria"), "must be an object when supplied"),
385 };
386 let unknown: Vec<&String> = criteria
387 .keys()
388 .filter(|key| key.as_str() != "true" && key.as_str() != "false")
389 .collect();
390 if !unknown.is_empty() {
391 let mut names: Vec<&str> = unknown.iter().map(|key| key.as_str()).collect();
392 names.sort_unstable();
393 return fail(
394 &format!("{base}.criteria"),
395 &format!("contains unsupported keys: {:?}", names),
396 );
397 }
398 let option_ids = vec!["true".to_string(), "false".to_string()];
399 let defaults = ["The answer is yes.", "The answer is no."];
400 let mut options = Vec::with_capacity(2);
401 for (index, option_id) in option_ids.iter().enumerate() {
402 let path = format!("{base}.criteria.{option_id}");
403 let fallback;
406 let value = match criteria.get(option_id) {
407 None => {
408 fallback = Value::String(defaults[index].to_string());
409 Some(&fallback)
410 }
411 Some(Value::Null) => None,
412 Some(value) => Some(value),
413 };
414 options.push(RowOption {
415 id: option_id.clone(),
416 description: description(option_id, value, &path)?,
417 });
418 }
419 Ok((option_ids, options, Vec::new()))
420}
421
422fn score_options(question: &Map<String, Value>, base: &str) -> Result<Options> {
423 let criteria = match question.get("criteria") {
424 Some(Value::Array(items)) if (2..=MAX_SCORE_LEVELS).contains(&items.len()) => items,
425 _ => {
426 return fail(
427 &format!("{base}.criteria"),
428 &format!("must contain 2-{MAX_SCORE_LEVELS} ordered levels"),
429 )
430 }
431 };
432 let mut option_ids = Vec::with_capacity(criteria.len());
433 let mut options = Vec::with_capacity(criteria.len());
434 let mut legend = Vec::with_capacity(criteria.len());
435 for (index, value) in criteria.iter().enumerate() {
436 let path = format!("{base}.criteria.{index}");
437 let text = json_text(value, &path)?;
438 let option_id = index.to_string();
439 legend.push(text.clone());
440 option_ids.push(option_id.clone());
441 options.push(RowOption {
442 id: option_id.clone(),
443 description: format!("Level {option_id}: {text}"),
444 });
445 }
446 Ok((option_ids, options, legend))
447}
448
449#[derive(Debug, Clone)]
451pub struct ScoredAnswer {
452 pub id: String,
454 pub option_ids: Vec<String>,
456 pub probabilities: Vec<f64>,
458 pub input_tokens: u64,
460 pub prompt_version: Option<String>,
462}
463
464pub fn distribution_confidence(probabilities: &[f64]) -> f64 {
466 let entropy: f64 = probabilities
467 .iter()
468 .filter(|value| **value > 0.0)
469 .map(|value| -value * value.ln())
470 .sum();
471 (1.0 - entropy / (probabilities.len() as f64).ln()).clamp(0.0, 1.0)
472}
473
474fn normalized_probabilities(spec: &QuestionSpec, result: &ScoredAnswer) -> Result<Vec<f64>> {
475 if result.id != spec.id || result.option_ids != spec.option_ids {
476 return Err(WireError(format!(
477 "Scorer result for {:?} does not match its declared options",
478 spec.id
479 )));
480 }
481 if result.probabilities.len() != spec.option_ids.len() {
482 return Err(WireError(format!(
483 "Scorer result for {:?} has an invalid probability vector",
484 spec.id
485 )));
486 }
487 if result
488 .probabilities
489 .iter()
490 .any(|value| !value.is_finite() || *value < 0.0)
491 {
492 return Err(WireError(format!(
493 "Scorer result for {:?} has non-finite or negative probabilities",
494 spec.id
495 )));
496 }
497 let total: f64 = result.probabilities.iter().sum();
498 if total <= 0.0 {
499 return Err(WireError(format!(
500 "Scorer result for {:?} has zero probability mass",
501 spec.id
502 )));
503 }
504 Ok(result.probabilities.iter().map(|value| value / total).collect())
505}
506
507#[derive(Debug)]
513pub struct OrderedMap<V>(Vec<(String, V)>);
514
515impl<V> OrderedMap<V> {
516 pub fn new() -> Self {
518 Self(Vec::new())
519 }
520
521 pub fn insert(&mut self, key: String, value: V) {
523 self.0.push((key, value));
524 }
525
526 pub fn is_empty(&self) -> bool {
528 self.0.is_empty()
529 }
530
531 pub fn len(&self) -> usize {
533 self.0.len()
534 }
535}
536
537impl<V> Default for OrderedMap<V> {
538 fn default() -> Self {
539 Self::new()
540 }
541}
542
543impl<V: Serialize> Serialize for OrderedMap<V> {
544 fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
545 use serde::ser::SerializeMap;
546 let mut map = serializer.serialize_map(Some(self.0.len()))?;
547 for (key, value) in &self.0 {
548 map.serialize_entry(key, value)?;
549 }
550 map.end()
551 }
552}
553
554#[derive(Debug, Serialize)]
561#[serde(tag = "type", rename_all = "lowercase")]
562pub enum Answer {
563 Noul {
565 noul: f64,
567 },
568 Choice {
570 choice: String,
572 probabilities: OrderedMap<f64>,
574 confidence: f64,
576 },
577 Score {
579 score: f64,
581 legend: OrderedMap<String>,
583 probabilities: OrderedMap<f64>,
585 confidence: f64,
587 },
588}
589
590#[derive(Debug, Serialize)]
591pub struct Usage {
593 pub input_tokens: u64,
595 pub output_tokens: u64,
597}
598
599#[derive(Debug, Serialize)]
601pub struct FastjevMeta {
602 pub probability_status: &'static str,
604 pub confidence_method: &'static str,
606 pub prompt_versions: Vec<String>,
608 pub readout: ReadoutStatus,
610}
611
612#[derive(Debug, Serialize)]
614pub struct SystemOneResponse {
615 pub model: String,
617 pub answers: OrderedMap<Answer>,
619 pub usage: Usage,
621 pub fastjev: FastjevMeta,
623}
624
625pub fn response_from_results(
627 served_model: &str,
628 specs: &[QuestionSpec],
629 results: &[ScoredAnswer],
630 readout: &ReadoutStatus,
631) -> Result<SystemOneResponse> {
632 if results.len() != specs.len() {
633 return Err(WireError(
634 "Scorer returned a different number of results than requested".to_string(),
635 ));
636 }
637 let mut answers = OrderedMap::new();
638 let mut input_tokens: u64 = 0;
639 let mut prompt_versions: Vec<String> = Vec::new();
640 for (spec, result) in specs.iter().zip(results) {
641 let probabilities = normalized_probabilities(spec, result)?;
642 let winner_index = probabilities
643 .iter()
644 .enumerate()
645 .fold(0usize, |best, (index, value)| {
646 if *value > probabilities[best] {
647 index
648 } else {
649 best
650 }
651 });
652 let mut distribution = OrderedMap::new();
653 for (option_id, value) in spec.option_ids.iter().zip(&probabilities) {
654 distribution.insert(option_id.clone(), *value);
655 }
656 let answer = match spec.kind {
657 Kind::Noul => {
658 let true_index = spec
659 .option_ids
660 .iter()
661 .position(|option_id| option_id == "true")
662 .ok_or_else(|| {
663 WireError(format!("Scorer result for {:?} lost its true option", spec.id))
664 })?;
665 Answer::Noul {
666 noul: probabilities[true_index],
667 }
668 }
669 Kind::Choice => Answer::Choice {
670 choice: spec.option_ids[winner_index].clone(),
671 probabilities: distribution,
672 confidence: distribution_confidence(&probabilities),
673 },
674 Kind::Score => {
675 let score: f64 = probabilities
676 .iter()
677 .enumerate()
678 .map(|(index, value)| index as f64 * value)
679 .sum();
680 let mut legend: OrderedMap<String> = OrderedMap::new();
681 for (index, text) in spec.legend.iter().enumerate() {
682 legend.insert(index.to_string(), text.clone());
683 }
684 Answer::Score {
685 score,
686 legend,
687 probabilities: distribution,
688 confidence: distribution_confidence(&probabilities),
689 }
690 }
691 };
692 answers.insert(spec.id.clone(), answer);
693 input_tokens += result.input_tokens;
694 if let Some(version) = &result.prompt_version
695 && !prompt_versions.contains(version)
696 {
697 prompt_versions.push(version.clone());
698 }
699 }
700 prompt_versions.sort();
701 Ok(SystemOneResponse {
702 model: served_model.to_string(),
703 answers,
704 usage: Usage {
705 input_tokens,
706 output_tokens: 0,
707 },
708 fastjev: FastjevMeta {
709 probability_status: PROBABILITY_STATUS,
710 confidence_method: CONFIDENCE_METHOD,
711 prompt_versions,
712 readout: readout.clone(),
713 },
714 })
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use serde_json::json;
721
722 fn sample_payload() -> Value {
723 json!({
724 "model": "bridge-model",
725 "state": "Help! My payouts have been failing for three days.",
726 "questions": {
727 "is_urgent": {"type": "noul", "instructions": "Does this convey urgency?"},
728 "department": {
729 "type": "choice",
730 "instructions": "Which team should handle this?",
731 "criteria": {"billing": "Payments, invoicing, and refunds", "technical": null},
732 },
733 "severity": {
734 "type": "score",
735 "instructions": "How severe is the problem?",
736 "criteria": ["Minor", "Degraded", "Blocking"],
737 },
738 },
739 })
740 }
741
742 #[test]
743 fn python_json_layout_matches_cpython_output() {
744 let value = json!({
748 "evidence": {"message": "已扣款两次 charged twice", "count": 2, "tags": ["a", "b"],
749 "ok": true, "missing": null, "ratio": 0.5},
750 "criterion": "Which queue?",
751 "options": [{"letter": "A", "description": "Billing, payments, and refunds."}],
752 });
753 assert_eq!(
754 to_python_json(&value),
755 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."}]}"#
756 );
757 }
758
759 #[test]
760 fn python_json_layout_matches_python_separators() {
761 let value = json!({"a": [1, 2], "b": {"c": "x"}, "d": "中"});
762 assert_eq!(to_python_json(&value), r#"{"a": [1, 2], "b": {"c": "x"}, "d": "中"}"#);
763 }
764
765 #[test]
766 fn rows_render_descriptions_like_fastjev() {
767 let batch = request_batch(&sample_payload(), "bridge-model").unwrap();
768 assert_eq!(batch.specs.len(), 3);
769 assert_eq!(batch.questions[1].options[0].description, "billing: Payments, invoicing, and refunds");
770 assert_eq!(batch.questions[1].options[1].description, "technical");
771 assert_eq!(batch.questions[2].options[2].description, "Level 2: Blocking");
772 assert_eq!(batch.questions[0].options[0].description, "true: The answer is yes.");
773 assert_eq!(batch.specs[2].legend, vec!["Minor", "Degraded", "Blocking"]);
774 assert_eq!(batch.questions[1].question, "Which team should handle this?");
775 assert_eq!(batch.state, json!("Help! My payouts have been failing for three days."));
777 }
778
779 #[test]
780 fn noul_criteria_defaults_and_nulls_differ() {
781 let payload = json!({
782 "model": "m",
783 "state": "s",
784 "questions": {
785 "defaults": {"type": "noul"},
786 "explicit_null": {"type": "noul", "criteria": {"true": null, "false": "No way."}},
787 },
788 });
789 let batch = request_batch(&payload, "m").unwrap();
790 assert_eq!(batch.questions[0].options[0].description, "true: The answer is yes.");
791 assert_eq!(batch.questions[0].options[1].description, "false: The answer is no.");
792 assert_eq!(batch.questions[1].options[0].description, "true");
793 assert_eq!(batch.questions[1].options[1].description, "false: No way.");
794 }
795
796 #[test]
797 fn model_mismatch_is_rejected() {
798 let error = request_batch(&sample_payload(), "other-model").unwrap_err();
799 assert!(error.0.starts_with("model: "), "{}", error.0);
800 }
801
802 #[test]
803 fn empty_state_is_rejected() {
804 let payload = json!({"model": "m", "state": "", "questions": {"q": {"type": "noul"}}});
805 let error = request_batch(&payload, "m").unwrap_err();
806 assert!(error.0.starts_with("state: "), "{}", error.0);
807 }
808
809 #[test]
810 fn response_uses_probabilities_and_confidence() {
811 let batch = request_batch(&sample_payload(), "bridge-model").unwrap();
812 let results = vec![
813 ScoredAnswer {
814 id: "is_urgent".into(),
815 option_ids: vec!["true".into(), "false".into()],
816 probabilities: vec![0.9, 0.1],
817 input_tokens: 10,
818 prompt_version: Some("direct-options-v1".into()),
819 },
820 ScoredAnswer {
821 id: "department".into(),
822 option_ids: vec!["billing".into(), "technical".into()],
823 probabilities: vec![0.25, 0.75],
824 input_tokens: 20,
825 prompt_version: Some("direct-options-v1".into()),
826 },
827 ScoredAnswer {
828 id: "severity".into(),
829 option_ids: vec!["0".into(), "1".into(), "2".into()],
830 probabilities: vec![0.0, 1.0, 0.0],
831 input_tokens: 30,
832 prompt_version: Some("direct-options-v1".into()),
833 },
834 ];
835 let response = serde_json::to_value(
836 response_from_results("bridge-model", &batch.specs, &results, &ReadoutStatus::default())
837 .unwrap(),
838 )
839 .unwrap();
840 assert_eq!(response["answers"]["is_urgent"]["noul"], 0.9);
841 assert_eq!(response["answers"]["department"]["choice"], "technical");
842 assert_eq!(response["answers"]["severity"]["score"], 1.0);
843 assert_eq!(response["usage"]["input_tokens"], 60);
844 assert_eq!(response["usage"]["output_tokens"], 0);
845 assert_eq!(response["answers"]["severity"]["legend"]["2"], "Blocking");
846 let confidence = response["answers"]["severity"]["confidence"].as_f64().unwrap();
847 assert!((confidence - 1.0).abs() < 1e-12);
848 }
849
850 #[test]
851 fn the_response_carries_the_declared_readout_status() {
852 let batch = request_batch(&sample_payload(), "bridge-model").unwrap();
853 let results = vec![
854 ScoredAnswer {
855 id: "is_urgent".into(),
856 option_ids: vec!["true".into(), "false".into()],
857 probabilities: vec![0.9, 0.1],
858 input_tokens: 10,
859 prompt_version: Some("direct-options-v1".into()),
860 },
861 ScoredAnswer {
862 id: "department".into(),
863 option_ids: vec!["billing".into(), "technical".into()],
864 probabilities: vec![0.25, 0.75],
865 input_tokens: 20,
866 prompt_version: Some("direct-options-v1".into()),
867 },
868 ScoredAnswer {
869 id: "severity".into(),
870 option_ids: vec!["0".into(), "1".into(), "2".into()],
871 probabilities: vec![0.0, 1.0, 0.0],
872 input_tokens: 30,
873 prompt_version: Some("direct-options-v1".into()),
874 },
875 ];
876
877 let response = serde_json::to_value(
879 response_from_results("bridge-model", &batch.specs, &results, &ReadoutStatus::default())
880 .unwrap(),
881 )
882 .unwrap();
883 assert_eq!(response["fastjev"]["readout"]["status"], UNVALIDATED_READOUT);
884 assert!(response["fastjev"]["readout"].get("evidence").is_none());
885
886 let declared = ReadoutStatus {
888 status: "validated".to_string(),
889 evidence: Some("argmax agreement 139/144".to_string()),
890 };
891 let response = serde_json::to_value(
892 response_from_results("bridge-model", &batch.specs, &results, &declared).unwrap(),
893 )
894 .unwrap();
895 assert_eq!(response["fastjev"]["readout"]["status"], "validated");
896 assert_eq!(
897 response["fastjev"]["readout"]["evidence"],
898 "argmax agreement 139/144"
899 );
900 }
901
902 #[test]
903 fn flat_distribution_has_zero_confidence() {
904 assert!(distribution_confidence(&[0.25, 0.25, 0.25, 0.25]).abs() < 1e-12);
905 }
906
907 #[test]
908 fn mismatched_result_is_rejected() {
909 let batch = request_batch(&sample_payload(), "bridge-model").unwrap();
910 let results = vec![ScoredAnswer {
911 id: "is_urgent".into(),
912 option_ids: vec!["false".into(), "true".into()],
913 probabilities: vec![0.5, 0.5],
914 input_tokens: 1,
915 prompt_version: None,
916 }];
917 assert!(
918 response_from_results("bridge-model", &batch.specs, &results, &ReadoutStatus::default())
919 .is_err()
920 );
921 }
922}