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
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
494pub enum ConfidenceMethod {
495 #[default]
497 Entropy,
498 MaxProbability,
501}
502
503pub const CONFIDENCE_METHOD_MAX_PROBABILITY: &str = "linear-top-probability";
505
506pub fn max_probability_confidence(probabilities: &[f64]) -> f64 {
509 if probabilities.len() < 2 {
510 return 1.0;
511 }
512 let maximum = probabilities.iter().copied().fold(f64::NEG_INFINITY, f64::max);
513 let count = probabilities.len() as f64;
514 ((count * maximum - 1.0) / (count - 1.0)).clamp(0.0, 1.0)
515}
516
517impl ConfidenceMethod {
518 pub fn name(self) -> &'static str {
520 match self {
521 ConfidenceMethod::Entropy => CONFIDENCE_METHOD,
522 ConfidenceMethod::MaxProbability => CONFIDENCE_METHOD_MAX_PROBABILITY,
523 }
524 }
525
526 pub fn compute(self, probabilities: &[f64]) -> f64 {
528 match self {
529 ConfidenceMethod::Entropy => distribution_confidence(probabilities),
530 ConfidenceMethod::MaxProbability => max_probability_confidence(probabilities),
531 }
532 }
533}
534
535fn normalized_probabilities(spec: &QuestionSpec, result: &ScoredAnswer) -> Result<Vec<f64>> {
536 if result.id != spec.id || result.option_ids != spec.option_ids {
537 return Err(WireError(format!(
538 "Scorer result for {:?} does not match its declared options",
539 spec.id
540 )));
541 }
542 if result.probabilities.len() != spec.option_ids.len() {
543 return Err(WireError(format!(
544 "Scorer result for {:?} has an invalid probability vector",
545 spec.id
546 )));
547 }
548 if result
549 .probabilities
550 .iter()
551 .any(|value| !value.is_finite() || *value < 0.0)
552 {
553 return Err(WireError(format!(
554 "Scorer result for {:?} has non-finite or negative probabilities",
555 spec.id
556 )));
557 }
558 let total: f64 = result.probabilities.iter().sum();
559 if total <= 0.0 {
560 return Err(WireError(format!(
561 "Scorer result for {:?} has zero probability mass",
562 spec.id
563 )));
564 }
565 Ok(result.probabilities.iter().map(|value| value / total).collect())
566}
567
568#[derive(Debug)]
574pub struct OrderedMap<V>(Vec<(String, V)>);
575
576impl<V> OrderedMap<V> {
577 pub fn new() -> Self {
579 Self(Vec::new())
580 }
581
582 pub fn insert(&mut self, key: String, value: V) {
584 self.0.push((key, value));
585 }
586
587 pub fn is_empty(&self) -> bool {
589 self.0.is_empty()
590 }
591
592 pub fn len(&self) -> usize {
594 self.0.len()
595 }
596}
597
598impl<V> Default for OrderedMap<V> {
599 fn default() -> Self {
600 Self::new()
601 }
602}
603
604impl<V: Serialize> Serialize for OrderedMap<V> {
605 fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
606 use serde::ser::SerializeMap;
607 let mut map = serializer.serialize_map(Some(self.0.len()))?;
608 for (key, value) in &self.0 {
609 map.serialize_entry(key, value)?;
610 }
611 map.end()
612 }
613}
614
615#[derive(Debug, Serialize)]
622#[serde(tag = "type", rename_all = "lowercase")]
623pub enum Answer {
624 Noul {
626 noul: f64,
628 },
629 Choice {
631 choice: String,
633 probabilities: OrderedMap<f64>,
635 confidence: f64,
637 },
638 Score {
640 score: f64,
642 legend: OrderedMap<String>,
644 probabilities: OrderedMap<f64>,
646 confidence: f64,
648 },
649}
650
651#[derive(Debug, Serialize)]
652pub struct Usage {
654 pub input_tokens: u64,
656 pub output_tokens: u64,
658}
659
660#[derive(Debug, Serialize)]
662pub struct FastjevMeta {
663 pub probability_status: &'static str,
665 pub confidence_method: &'static str,
667 pub prompt_versions: Vec<String>,
669 pub readout: ReadoutStatus,
671}
672
673#[derive(Debug, Serialize)]
675pub struct SystemOneResponse {
676 pub model: String,
678 pub answers: OrderedMap<Answer>,
680 pub usage: Usage,
682 pub fastjev: FastjevMeta,
684}
685
686pub fn response_from_results(
688 served_model: &str,
689 specs: &[QuestionSpec],
690 results: &[ScoredAnswer],
691 readout: &ReadoutStatus,
692 confidence: ConfidenceMethod,
693) -> Result<SystemOneResponse> {
694 if results.len() != specs.len() {
695 return Err(WireError(
696 "Scorer returned a different number of results than requested".to_string(),
697 ));
698 }
699 let mut answers = OrderedMap::new();
700 let mut input_tokens: u64 = 0;
701 let mut prompt_versions: Vec<String> = Vec::new();
702 for (spec, result) in specs.iter().zip(results) {
703 let probabilities = normalized_probabilities(spec, result)?;
704 let winner_index = probabilities
705 .iter()
706 .enumerate()
707 .fold(0usize, |best, (index, value)| {
708 if *value > probabilities[best] {
709 index
710 } else {
711 best
712 }
713 });
714 let mut distribution = OrderedMap::new();
715 for (option_id, value) in spec.option_ids.iter().zip(&probabilities) {
716 distribution.insert(option_id.clone(), *value);
717 }
718 let answer = match spec.kind {
719 Kind::Noul => {
720 let true_index = spec
721 .option_ids
722 .iter()
723 .position(|option_id| option_id == "true")
724 .ok_or_else(|| {
725 WireError(format!("Scorer result for {:?} lost its true option", spec.id))
726 })?;
727 Answer::Noul {
728 noul: probabilities[true_index],
729 }
730 }
731 Kind::Choice => Answer::Choice {
732 choice: spec.option_ids[winner_index].clone(),
733 probabilities: distribution,
734 confidence: distribution_confidence(&probabilities),
735 },
736 Kind::Score => {
737 let score: f64 = probabilities
738 .iter()
739 .enumerate()
740 .map(|(index, value)| index as f64 * value)
741 .sum();
742 let mut legend: OrderedMap<String> = OrderedMap::new();
743 for (index, text) in spec.legend.iter().enumerate() {
744 legend.insert(index.to_string(), text.clone());
745 }
746 Answer::Score {
747 score,
748 legend,
749 probabilities: distribution,
750 confidence: confidence.compute(&probabilities),
751 }
752 }
753 };
754 answers.insert(spec.id.clone(), answer);
755 input_tokens += result.input_tokens;
756 if let Some(version) = &result.prompt_version
757 && !prompt_versions.contains(version)
758 {
759 prompt_versions.push(version.clone());
760 }
761 }
762 prompt_versions.sort();
763 Ok(SystemOneResponse {
764 model: served_model.to_string(),
765 answers,
766 usage: Usage {
767 input_tokens,
768 output_tokens: 0,
769 },
770 fastjev: FastjevMeta {
771 probability_status: PROBABILITY_STATUS,
772 confidence_method: confidence.name(),
773 prompt_versions,
774 readout: readout.clone(),
775 },
776 })
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use serde_json::json;
783
784 fn sample_payload() -> Value {
785 json!({
786 "model": "bridge-model",
787 "state": "Help! My payouts have been failing for three days.",
788 "questions": {
789 "is_urgent": {"type": "noul", "instructions": "Does this convey urgency?"},
790 "department": {
791 "type": "choice",
792 "instructions": "Which team should handle this?",
793 "criteria": {"billing": "Payments, invoicing, and refunds", "technical": null},
794 },
795 "severity": {
796 "type": "score",
797 "instructions": "How severe is the problem?",
798 "criteria": ["Minor", "Degraded", "Blocking"],
799 },
800 },
801 })
802 }
803
804 #[test]
805 fn python_json_layout_matches_cpython_output() {
806 let value = json!({
810 "evidence": {"message": "已扣款两次 charged twice", "count": 2, "tags": ["a", "b"],
811 "ok": true, "missing": null, "ratio": 0.5},
812 "criterion": "Which queue?",
813 "options": [{"letter": "A", "description": "Billing, payments, and refunds."}],
814 });
815 assert_eq!(
816 to_python_json(&value),
817 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."}]}"#
818 );
819 }
820
821 #[test]
822 fn python_json_layout_matches_python_separators() {
823 let value = json!({"a": [1, 2], "b": {"c": "x"}, "d": "中"});
824 assert_eq!(to_python_json(&value), r#"{"a": [1, 2], "b": {"c": "x"}, "d": "中"}"#);
825 }
826
827 #[test]
828 fn rows_render_descriptions_like_fastjev() {
829 let batch = request_batch(&sample_payload(), "bridge-model", false).unwrap();
830 assert_eq!(batch.specs.len(), 3);
831 assert_eq!(batch.questions[1].options[0].description, "billing: Payments, invoicing, and refunds");
832 assert_eq!(batch.questions[1].options[1].description, "technical");
833 assert_eq!(batch.questions[2].options[2].description, "Level 2: Blocking");
834 assert_eq!(batch.questions[0].options[0].description, "true: The answer is yes.");
835 assert_eq!(batch.specs[2].legend, vec!["Minor", "Degraded", "Blocking"]);
836 assert_eq!(batch.questions[1].question, "Which team should handle this?");
837 assert_eq!(batch.state, json!("Help! My payouts have been failing for three days."));
839 }
840
841 #[test]
842 fn noul_criteria_defaults_and_nulls_differ() {
843 let payload = json!({
844 "model": "m",
845 "state": "s",
846 "questions": {
847 "defaults": {"type": "noul"},
848 "explicit_null": {"type": "noul", "criteria": {"true": null, "false": "No way."}},
849 },
850 });
851 let batch = request_batch(&payload, "m", false).unwrap();
852 assert_eq!(batch.questions[0].options[0].description, "true: The answer is yes.");
853 assert_eq!(batch.questions[0].options[1].description, "false: The answer is no.");
854 assert_eq!(batch.questions[1].options[0].description, "true");
855 assert_eq!(batch.questions[1].options[1].description, "false: No way.");
856 }
857
858 #[test]
859 fn model_mismatch_is_rejected() {
860 let error = request_batch(&sample_payload(), "other-model", false).unwrap_err();
861 assert!(error.0.starts_with("model: "), "{}", error.0);
862 }
863
864 #[test]
865 fn empty_state_is_rejected() {
866 let payload = json!({"model": "m", "state": "", "questions": {"q": {"type": "noul"}}});
867 let error = request_batch(&payload, "m", false).unwrap_err();
868 assert!(error.0.starts_with("state: "), "{}", error.0);
869 }
870
871 #[test]
872 fn response_uses_probabilities_and_confidence() {
873 let batch = request_batch(&sample_payload(), "bridge-model", false).unwrap();
874 let results = vec![
875 ScoredAnswer {
876 id: "is_urgent".into(),
877 option_ids: vec!["true".into(), "false".into()],
878 probabilities: vec![0.9, 0.1],
879 input_tokens: 10,
880 prompt_version: Some("direct-options-v1".into()),
881 },
882 ScoredAnswer {
883 id: "department".into(),
884 option_ids: vec!["billing".into(), "technical".into()],
885 probabilities: vec![0.25, 0.75],
886 input_tokens: 20,
887 prompt_version: Some("direct-options-v1".into()),
888 },
889 ScoredAnswer {
890 id: "severity".into(),
891 option_ids: vec!["0".into(), "1".into(), "2".into()],
892 probabilities: vec![0.0, 1.0, 0.0],
893 input_tokens: 30,
894 prompt_version: Some("direct-options-v1".into()),
895 },
896 ];
897 let response = serde_json::to_value(
898 response_from_results("bridge-model", &batch.specs, &results, &ReadoutStatus::default(), ConfidenceMethod::Entropy)
899 .unwrap(),
900 )
901 .unwrap();
902 assert_eq!(response["answers"]["is_urgent"]["noul"], 0.9);
903 assert_eq!(response["answers"]["department"]["choice"], "technical");
904 assert_eq!(response["answers"]["severity"]["score"], 1.0);
905 assert_eq!(response["usage"]["input_tokens"], 60);
906 assert_eq!(response["usage"]["output_tokens"], 0);
907 assert_eq!(response["answers"]["severity"]["legend"]["2"], "Blocking");
908 let confidence = response["answers"]["severity"]["confidence"].as_f64().unwrap();
909 assert!((confidence - 1.0).abs() < 1e-12);
910 }
911
912 #[test]
913 fn the_response_carries_the_declared_readout_status() {
914 let batch = request_batch(&sample_payload(), "bridge-model", false).unwrap();
915 let results = vec![
916 ScoredAnswer {
917 id: "is_urgent".into(),
918 option_ids: vec!["true".into(), "false".into()],
919 probabilities: vec![0.9, 0.1],
920 input_tokens: 10,
921 prompt_version: Some("direct-options-v1".into()),
922 },
923 ScoredAnswer {
924 id: "department".into(),
925 option_ids: vec!["billing".into(), "technical".into()],
926 probabilities: vec![0.25, 0.75],
927 input_tokens: 20,
928 prompt_version: Some("direct-options-v1".into()),
929 },
930 ScoredAnswer {
931 id: "severity".into(),
932 option_ids: vec!["0".into(), "1".into(), "2".into()],
933 probabilities: vec![0.0, 1.0, 0.0],
934 input_tokens: 30,
935 prompt_version: Some("direct-options-v1".into()),
936 },
937 ];
938
939 let response = serde_json::to_value(
941 response_from_results("bridge-model", &batch.specs, &results, &ReadoutStatus::default(), ConfidenceMethod::Entropy)
942 .unwrap(),
943 )
944 .unwrap();
945 assert_eq!(response["fastjev"]["readout"]["status"], UNVALIDATED_READOUT);
946 assert!(response["fastjev"]["readout"].get("evidence").is_none());
947
948 let declared = ReadoutStatus {
950 status: "validated".to_string(),
951 evidence: Some("argmax agreement 139/144".to_string()),
952 };
953 let response = serde_json::to_value(
954 response_from_results(
955 "bridge-model",
956 &batch.specs,
957 &results,
958 &declared,
959 ConfidenceMethod::Entropy,
960 )
961 .unwrap(),
962 )
963 .unwrap();
964 assert_eq!(response["fastjev"]["readout"]["status"], "validated");
965 assert_eq!(
966 response["fastjev"]["readout"]["evidence"],
967 "argmax agreement 139/144"
968 );
969 }
970
971 #[test]
972 fn flat_distribution_has_zero_confidence() {
973 assert!(distribution_confidence(&[0.25, 0.25, 0.25, 0.25]).abs() < 1e-12);
974 }
975
976 #[test]
977 fn mismatched_result_is_rejected() {
978 let batch = request_batch(&sample_payload(), "bridge-model", false).unwrap();
979 let results = vec![ScoredAnswer {
980 id: "is_urgent".into(),
981 option_ids: vec!["false".into(), "true".into()],
982 probabilities: vec![0.5, 0.5],
983 input_tokens: 1,
984 prompt_version: None,
985 }];
986 assert!(
987 response_from_results("bridge-model", &batch.specs, &results, &ReadoutStatus::default(), ConfidenceMethod::Entropy)
988 .is_err()
989 );
990 }
991}