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.
235///
236/// `accept_any_model` relaxes the `model` check from "must name the served
237/// model" to "must be a non-empty string": third-party TypeSafe clients (the
238/// `@ai-sdk/typesafe-ai` SDK, the djev-run demos) hard-code a model id like
239/// `jev-latest`, and a deployment that wants to serve them can say so
240/// explicitly. The response still reports the served model, so the bridge
241/// never claims to be something it is not.
242pub 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        // A missing key takes the default text; a key that is present but null
419        // renders as the bare option id. Both are then prefixed with the id.
420        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/// A scorer result reduced to what the wire layer needs.
465#[derive(Debug, Clone)]
466pub struct ScoredAnswer {
467    /// Row id, copied from the request.
468    pub id: String,
469    /// Option ids in the order the probabilities follow.
470    pub option_ids: Vec<String>,
471    /// One probability per option id, summing to one.
472    pub probabilities: Vec<f64>,
473    /// Prompt length the runtime reported.
474    pub input_tokens: u64,
475    /// Prompt contract version, when the backend recorded one.
476    pub prompt_version: Option<String>,
477}
478
479/// Documented fastjev certainty proxy, not TypeSafe's private statistic.
480pub 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/// The certainty proxy that turns one distribution into one number.
490///
491/// Neither formula is TypeSafe's; the response declares which one produced its
492/// `confidence` values.
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
494pub enum ConfidenceMethod {
495    /// `1 - H / ln(K)`: concentration over the whole distribution.
496    #[default]
497    Entropy,
498    /// `(K * max(p) - 1) / (K - 1)`: linear in the top probability, the shape
499    /// the binary-candidate reference implementation uses.
500    MaxProbability,
501}
502
503/// The method name for [`ConfidenceMethod::MaxProbability`].
504pub const CONFIDENCE_METHOD_MAX_PROBABILITY: &str = "linear-top-probability";
505
506/// Linear top-probability certainty proxy: zero on a uniform distribution,
507/// one when a single candidate holds all the mass.
508pub 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    /// The name recorded in the response.
519    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    /// Compute the proxy for one distribution.
527    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/// An ordered map that serialises as a JSON object.
569///
570/// `serde_json::Map` only holds `Value`, and option order matters for
571/// readability because it follows the caller's own criteria order, so the pairs
572/// stay in a vector and are written out as an object.
573#[derive(Debug)]
574pub struct OrderedMap<V>(Vec<(String, V)>);
575
576impl<V> OrderedMap<V> {
577    /// An empty map.
578    pub fn new() -> Self {
579        Self(Vec::new())
580    }
581
582    /// Append a key, keeping insertion order.
583    pub fn insert(&mut self, key: String, value: V) {
584        self.0.push((key, value));
585    }
586
587    /// Whether no entry has been inserted.
588    pub fn is_empty(&self) -> bool {
589        self.0.is_empty()
590    }
591
592    /// How many entries were inserted.
593    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/// One answer in a System One response.
616///
617/// The `type` tag is what makes the three shapes distinguishable: a `choice`
618/// carries a winner and a distribution, a `noul` carries the probability of
619/// `true`, and a `score` carries a probability-weighted value plus the legend
620/// that maps its levels back to text.
621#[derive(Debug, Serialize)]
622#[serde(tag = "type", rename_all = "lowercase")]
623pub enum Answer {
624    /// Answer a yes/no question.
625    Noul {
626        /// Probability assigned to `true`.
627        noul: f64,
628    },
629    /// Pick one of several described options.
630    Choice {
631        /// The winning option id.
632        choice: String,
633        /// Full distribution over the declared options.
634        probabilities: OrderedMap<f64>,
635        /// Certainty proxy; see [`CONFIDENCE_METHOD`].
636        confidence: f64,
637    },
638    /// Pick one of several ordered levels.
639    Score {
640        /// Probability-weighted level value.
641        score: f64,
642        /// Level index to level text, in declared order.
643        legend: OrderedMap<String>,
644        /// Full distribution over the declared levels.
645        probabilities: OrderedMap<f64>,
646        /// Certainty proxy; see [`CONFIDENCE_METHOD`].
647        confidence: f64,
648    },
649}
650
651#[derive(Debug, Serialize)]
652/// Token accounting for one request.
653pub struct Usage {
654    /// Sum of the per-question prompt lengths.
655    pub input_tokens: u64,
656    /// Always zero: the bridge reads log probabilities instead of generating.
657    pub output_tokens: u64,
658}
659
660/// The bridge's own extension block, mirroring fastjev's System One adapter.
661#[derive(Debug, Serialize)]
662pub struct FastjevMeta {
663    /// The uncalibrated-probability disclaimer.
664    pub probability_status: &'static str,
665    /// How `confidence` was derived.
666    pub confidence_method: &'static str,
667    /// Distinct prompt versions that produced this response.
668    pub prompt_versions: Vec<String>,
669    /// Whether the readout channel was validated on the served model.
670    pub readout: ReadoutStatus,
671}
672
673/// The documented System One response shape.
674#[derive(Debug, Serialize)]
675pub struct SystemOneResponse {
676    /// The served model id, echoed from the request.
677    pub model: String,
678    /// One answer per question id, in request order.
679    pub answers: OrderedMap<Answer>,
680    /// Token accounting.
681    pub usage: Usage,
682    /// Bridge-specific metadata.
683    pub fastjev: FastjevMeta,
684}
685
686/// Convert scorer results to the documented System One response shape.
687pub 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        // The expected string is the exact stdout of CPython:
807        //   json.dumps(payload, ensure_ascii=False)
808        // for the same object, including nesting, booleans, null and non-ASCII.
809        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        // One state, shared by every question rather than copied per question.
838        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        // Default: unvalidated, with no evidence attached.
940        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        // Declared: the status and its evidence both travel with the response.
949        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}