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
188impl Row {
189    /// Split a row into the state it carries and the question asked about it.
190    ///
191    /// A `--score` fixture gives every row its own state; a System One request
192    /// gives one state to every question. This is the boundary between the two
193    /// shapes.
194    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/// One question asked about a shared state.
207///
208/// Deliberately without a `state` field: the caller holds one state and passes
209/// it once, so a batch of questions does not copy the evidence per question.
210#[derive(Debug, Clone, serde::Deserialize)]
211pub struct Question {
212    /// Question id, echoed into the score.
213    pub id: String,
214    /// The criterion to apply to the state.
215    pub question: String,
216    /// Between 2 and [`MAX_OPTIONS`] options.
217    pub options: Vec<RowOption>,
218}
219
220/// A validated System One request, reduced to what the scorer needs: the one
221/// state every question shares, and the questions themselves.
222#[derive(Debug)]
223pub struct RequestBatch {
224    /// Answer-shaping specs, in question order.
225    pub specs: Vec<QuestionSpec>,
226    /// The shared evidence, handed to the scorer once rather than copied per
227    /// question.
228    pub state: Value,
229    /// The questions asked about it, in request order.
230    pub questions: Vec<Question>,
231}
232
233/// Validate a System One request and convert it to one shared state plus the
234/// questions asked about it.
235pub 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        // A missing key takes the default text; a key that is present but null
404        // renders as the bare option id. Both are then prefixed with the id.
405        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/// A scorer result reduced to what the wire layer needs.
450#[derive(Debug, Clone)]
451pub struct ScoredAnswer {
452    /// Row id, copied from the request.
453    pub id: String,
454    /// Option ids in the order the probabilities follow.
455    pub option_ids: Vec<String>,
456    /// One probability per option id, summing to one.
457    pub probabilities: Vec<f64>,
458    /// Prompt length the runtime reported.
459    pub input_tokens: u64,
460    /// Prompt contract version, when the backend recorded one.
461    pub prompt_version: Option<String>,
462}
463
464/// Documented fastjev certainty proxy, not TypeSafe's private statistic.
465pub 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/// An ordered map that serialises as a JSON object.
508///
509/// `serde_json::Map` only holds `Value`, and option order matters for
510/// readability because it follows the caller's own criteria order, so the pairs
511/// stay in a vector and are written out as an object.
512#[derive(Debug)]
513pub struct OrderedMap<V>(Vec<(String, V)>);
514
515impl<V> OrderedMap<V> {
516    /// An empty map.
517    pub fn new() -> Self {
518        Self(Vec::new())
519    }
520
521    /// Append a key, keeping insertion order.
522    pub fn insert(&mut self, key: String, value: V) {
523        self.0.push((key, value));
524    }
525
526    /// Whether no entry has been inserted.
527    pub fn is_empty(&self) -> bool {
528        self.0.is_empty()
529    }
530
531    /// How many entries were inserted.
532    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/// One answer in a System One response.
555///
556/// The `type` tag is what makes the three shapes distinguishable: a `choice`
557/// carries a winner and a distribution, a `noul` carries the probability of
558/// `true`, and a `score` carries a probability-weighted value plus the legend
559/// that maps its levels back to text.
560#[derive(Debug, Serialize)]
561#[serde(tag = "type", rename_all = "lowercase")]
562pub enum Answer {
563    /// Answer a yes/no question.
564    Noul {
565        /// Probability assigned to `true`.
566        noul: f64,
567    },
568    /// Pick one of several described options.
569    Choice {
570        /// The winning option id.
571        choice: String,
572        /// Full distribution over the declared options.
573        probabilities: OrderedMap<f64>,
574        /// Certainty proxy; see [`CONFIDENCE_METHOD`].
575        confidence: f64,
576    },
577    /// Pick one of several ordered levels.
578    Score {
579        /// Probability-weighted level value.
580        score: f64,
581        /// Level index to level text, in declared order.
582        legend: OrderedMap<String>,
583        /// Full distribution over the declared levels.
584        probabilities: OrderedMap<f64>,
585        /// Certainty proxy; see [`CONFIDENCE_METHOD`].
586        confidence: f64,
587    },
588}
589
590#[derive(Debug, Serialize)]
591/// Token accounting for one request.
592pub struct Usage {
593    /// Sum of the per-question prompt lengths.
594    pub input_tokens: u64,
595    /// Always zero: the bridge reads log probabilities instead of generating.
596    pub output_tokens: u64,
597}
598
599/// The bridge's own extension block, mirroring fastjev's System One adapter.
600#[derive(Debug, Serialize)]
601pub struct FastjevMeta {
602    /// The uncalibrated-probability disclaimer.
603    pub probability_status: &'static str,
604    /// How `confidence` was derived.
605    pub confidence_method: &'static str,
606    /// Distinct prompt versions that produced this response.
607    pub prompt_versions: Vec<String>,
608    /// Whether the readout channel was validated on the served model.
609    pub readout: ReadoutStatus,
610}
611
612/// The documented System One response shape.
613#[derive(Debug, Serialize)]
614pub struct SystemOneResponse {
615    /// The served model id, echoed from the request.
616    pub model: String,
617    /// One answer per question id, in request order.
618    pub answers: OrderedMap<Answer>,
619    /// Token accounting.
620    pub usage: Usage,
621    /// Bridge-specific metadata.
622    pub fastjev: FastjevMeta,
623}
624
625/// Convert scorer results to the documented System One response shape.
626pub 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        // The expected string is the exact stdout of CPython:
745        //   json.dumps(payload, ensure_ascii=False)
746        // for the same object, including nesting, booleans, null and non-ASCII.
747        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        // One state, shared by every question rather than copied per question.
776        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        // Default: unvalidated, with no evidence attached.
878        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        // Declared: the status and its evidence both travel with the response.
887        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}