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