Skip to main content

jev_bridge/
wire.rs

1//! System One wire format: request validation and response construction.
2//!
3//! This mirrors the contract implemented by fastjev's `compat/wire.py`
4//! (<https://github.com/chengyongru/fastjev>, MIT). The adapter preserves the
5//! public wire shape; it does not claim Jev's model behaviour, and the
6//! returned probabilities are conditional option scores, not calibrated
7//! decision confidence.
8
9use serde::Serialize;
10use serde_json::{Map, Value};
11use std::io;
12
13/// The largest number of options a `choice` question may offer.
14///
15/// Sixteen matches the sixteen uppercase letters the readout can address.
16pub const MAX_OPTIONS: usize = 16;
17
18/// The largest number of levels a `score` question may offer.
19pub const MAX_SCORE_LEVELS: usize = 10;
20
21/// How the bridge derives its certainty proxy.
22///
23/// This is `1 - normalized entropy`, documented as such because TypeSafe does
24/// not publish the statistic behind its own `confidence` field.
25pub const CONFIDENCE_METHOD: &str = "one-minus-normalized-entropy";
26
27/// The disclaimer attached to every returned probability.
28pub const PROBABILITY_STATUS: &str = "conditional option scores; uncalibrated as decision confidence";
29
30/// The default [`ReadoutStatus::status`]: nothing was checked on this model.
31pub const UNVALIDATED_READOUT: &str = "unvalidated";
32
33/// What the deployment knows about this bridge's readout channel.
34///
35/// The readout — one token per answer letter, log probabilities at the final
36/// position — assumes the served model can actually use single-token slot
37/// letters. A general instruct model that was never trained for it does not
38/// fail loudly: it degrades silently, typically by acquiring a probability
39/// floor on a catch-all option. The bridge cannot prove readout quality at
40/// runtime without gold data, so it reports what the deployment declared:
41/// `unvalidated` by default, or `validated` with the evidence attached (for
42/// example, an alignment run scored with [`crate::evaluate`]).
43///
44/// This is deliberately separate from [`PROBABILITY_STATUS`]: that field
45/// describes what the numbers mean (conditional option scores), this one
46/// describes whether the channel that produced them was checked on this model.
47#[derive(Debug, Clone, PartialEq, Serialize)]
48pub struct ReadoutStatus {
49    /// Validation status; conventions: [`UNVALIDATED_READOUT`] (the default)
50    /// or `validated`.
51    pub status: String,
52    /// Free-form evidence for the status, for example an alignment summary.
53    #[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/// A request that cannot be represented by the scoring backend.
67#[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
84/// Serialize with Python's `json.dumps(..., ensure_ascii=False)` layout.
85///
86/// fastjev builds its prompt payload with Python's default separators
87/// (`", "` and `": "`), so matching that layout byte for byte is what keeps a
88/// bridged prompt identical to the reference implementation's.
89pub 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/// The three question shapes the System One API defines.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Kind {
134    /// Pick one of several described options.
135    Choice,
136    /// Answer a yes/no proposition; maps to the two options `true` and `false`.
137    Noul,
138    /// Pick one of several ordered levels; the answer is a weighted value.
139    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/// What a validated question asks for, kept for building the response.
154#[derive(Debug, Clone)]
155pub struct QuestionSpec {
156    /// The caller's question id, echoed back as the answer key.
157    pub id: String,
158    /// Which of the three shapes this question is.
159    pub kind: Kind,
160    /// Option ids in the order they were declared, which fixes the answer order.
161    pub option_ids: Vec<String>,
162    /// Level texts for a `score` question, empty otherwise.
163    pub legend: Vec<String>,
164}
165
166/// One answer option handed to the scorer.
167#[derive(Debug, Clone, serde::Deserialize)]
168pub struct RowOption {
169    /// Stable option id returned as the winning value.
170    pub id: String,
171    /// The text the model actually reads, after any id prefix was added.
172    pub description: String,
173}
174
175/// One decision, in the shape the scorer and the `--score` fixture both use.
176#[derive(Debug, Clone, serde::Deserialize)]
177pub struct Row {
178    /// Row id, echoed into the prediction.
179    pub id: String,
180    /// Arbitrary JSON evidence: a string, an object or an array.
181    pub state: Value,
182    /// The criterion to apply to the state.
183    pub question: String,
184    /// Between 2 and [`MAX_OPTIONS`] options.
185    pub options: Vec<RowOption>,
186}
187
188/// Validate a System One request and convert its questions to scorer rows.
189pub 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        // A missing key takes the default text; a key that is present but null
355        // renders as the bare option id. Both are then prefixed with the id.
356        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/// A scorer result reduced to what the wire layer needs.
401#[derive(Debug, Clone)]
402pub struct ScoredAnswer {
403    /// Row id, copied from the request.
404    pub id: String,
405    /// Option ids in the order the probabilities follow.
406    pub option_ids: Vec<String>,
407    /// One probability per option id, summing to one.
408    pub probabilities: Vec<f64>,
409    /// Prompt length the runtime reported.
410    pub input_tokens: u64,
411    /// Prompt contract version, when the backend recorded one.
412    pub prompt_version: Option<String>,
413}
414
415/// Documented fastjev certainty proxy, not TypeSafe's private statistic.
416pub 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/// An ordered map that serialises as a JSON object.
459///
460/// `serde_json::Map` only holds `Value`, and option order matters for
461/// readability because it follows the caller's own criteria order, so the pairs
462/// stay in a vector and are written out as an object.
463#[derive(Debug)]
464pub struct OrderedMap<V>(Vec<(String, V)>);
465
466impl<V> OrderedMap<V> {
467    /// An empty map.
468    pub fn new() -> Self {
469        Self(Vec::new())
470    }
471
472    /// Append a key, keeping insertion order.
473    pub fn insert(&mut self, key: String, value: V) {
474        self.0.push((key, value));
475    }
476
477    /// Whether no entry has been inserted.
478    pub fn is_empty(&self) -> bool {
479        self.0.is_empty()
480    }
481
482    /// How many entries were inserted.
483    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/// One answer in a System One response.
506///
507/// The `type` tag is what makes the three shapes distinguishable: a `choice`
508/// carries a winner and a distribution, a `noul` carries the probability of
509/// `true`, and a `score` carries a probability-weighted value plus the legend
510/// that maps its levels back to text.
511#[derive(Debug, Serialize)]
512#[serde(tag = "type", rename_all = "lowercase")]
513pub enum Answer {
514    /// Answer a yes/no question.
515    Noul {
516        /// Probability assigned to `true`.
517        noul: f64,
518    },
519    /// Pick one of several described options.
520    Choice {
521        /// The winning option id.
522        choice: String,
523        /// Full distribution over the declared options.
524        probabilities: OrderedMap<f64>,
525        /// Certainty proxy; see [`CONFIDENCE_METHOD`].
526        confidence: f64,
527    },
528    /// Pick one of several ordered levels.
529    Score {
530        /// Probability-weighted level value.
531        score: f64,
532        /// Level index to level text, in declared order.
533        legend: OrderedMap<String>,
534        /// Full distribution over the declared levels.
535        probabilities: OrderedMap<f64>,
536        /// Certainty proxy; see [`CONFIDENCE_METHOD`].
537        confidence: f64,
538    },
539}
540
541#[derive(Debug, Serialize)]
542/// Token accounting for one request.
543pub struct Usage {
544    /// Sum of the per-question prompt lengths.
545    pub input_tokens: u64,
546    /// Always zero: the bridge reads log probabilities instead of generating.
547    pub output_tokens: u64,
548}
549
550/// The bridge's own extension block, mirroring fastjev's System One adapter.
551#[derive(Debug, Serialize)]
552pub struct FastjevMeta {
553    /// The uncalibrated-probability disclaimer.
554    pub probability_status: &'static str,
555    /// How `confidence` was derived.
556    pub confidence_method: &'static str,
557    /// Distinct prompt versions that produced this response.
558    pub prompt_versions: Vec<String>,
559    /// Whether the readout channel was validated on the served model.
560    pub readout: ReadoutStatus,
561}
562
563/// The documented System One response shape.
564#[derive(Debug, Serialize)]
565pub struct SystemOneResponse {
566    /// The served model id, echoed from the request.
567    pub model: String,
568    /// One answer per question id, in request order.
569    pub answers: OrderedMap<Answer>,
570    /// Token accounting.
571    pub usage: Usage,
572    /// Bridge-specific metadata.
573    pub fastjev: FastjevMeta,
574}
575
576/// Convert scorer results to the documented System One response shape.
577pub 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        // The expected string is the exact stdout of CPython:
696        //   json.dumps(payload, ensure_ascii=False)
697        // for the same object, including nesting, booleans, null and non-ASCII.
698        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        // Default: unvalidated, with no evidence attached.
827        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        // Declared: the status and its evidence both travel with the response.
836        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}