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
489fn normalized_probabilities(spec: &QuestionSpec, result: &ScoredAnswer) -> Result<Vec<f64>> {
490    if result.id != spec.id || result.option_ids != spec.option_ids {
491        return Err(WireError(format!(
492            "Scorer result for {:?} does not match its declared options",
493            spec.id
494        )));
495    }
496    if result.probabilities.len() != spec.option_ids.len() {
497        return Err(WireError(format!(
498            "Scorer result for {:?} has an invalid probability vector",
499            spec.id
500        )));
501    }
502    if result
503        .probabilities
504        .iter()
505        .any(|value| !value.is_finite() || *value < 0.0)
506    {
507        return Err(WireError(format!(
508            "Scorer result for {:?} has non-finite or negative probabilities",
509            spec.id
510        )));
511    }
512    let total: f64 = result.probabilities.iter().sum();
513    if total <= 0.0 {
514        return Err(WireError(format!(
515            "Scorer result for {:?} has zero probability mass",
516            spec.id
517        )));
518    }
519    Ok(result.probabilities.iter().map(|value| value / total).collect())
520}
521
522/// An ordered map that serialises as a JSON object.
523///
524/// `serde_json::Map` only holds `Value`, and option order matters for
525/// readability because it follows the caller's own criteria order, so the pairs
526/// stay in a vector and are written out as an object.
527#[derive(Debug)]
528pub struct OrderedMap<V>(Vec<(String, V)>);
529
530impl<V> OrderedMap<V> {
531    /// An empty map.
532    pub fn new() -> Self {
533        Self(Vec::new())
534    }
535
536    /// Append a key, keeping insertion order.
537    pub fn insert(&mut self, key: String, value: V) {
538        self.0.push((key, value));
539    }
540
541    /// Whether no entry has been inserted.
542    pub fn is_empty(&self) -> bool {
543        self.0.is_empty()
544    }
545
546    /// How many entries were inserted.
547    pub fn len(&self) -> usize {
548        self.0.len()
549    }
550}
551
552impl<V> Default for OrderedMap<V> {
553    fn default() -> Self {
554        Self::new()
555    }
556}
557
558impl<V: Serialize> Serialize for OrderedMap<V> {
559    fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
560        use serde::ser::SerializeMap;
561        let mut map = serializer.serialize_map(Some(self.0.len()))?;
562        for (key, value) in &self.0 {
563            map.serialize_entry(key, value)?;
564        }
565        map.end()
566    }
567}
568
569/// One answer in a System One response.
570///
571/// The `type` tag is what makes the three shapes distinguishable: a `choice`
572/// carries a winner and a distribution, a `noul` carries the probability of
573/// `true`, and a `score` carries a probability-weighted value plus the legend
574/// that maps its levels back to text.
575#[derive(Debug, Serialize)]
576#[serde(tag = "type", rename_all = "lowercase")]
577pub enum Answer {
578    /// Answer a yes/no question.
579    Noul {
580        /// Probability assigned to `true`.
581        noul: f64,
582    },
583    /// Pick one of several described options.
584    Choice {
585        /// The winning option id.
586        choice: String,
587        /// Full distribution over the declared options.
588        probabilities: OrderedMap<f64>,
589        /// Certainty proxy; see [`CONFIDENCE_METHOD`].
590        confidence: f64,
591    },
592    /// Pick one of several ordered levels.
593    Score {
594        /// Probability-weighted level value.
595        score: f64,
596        /// Level index to level text, in declared order.
597        legend: OrderedMap<String>,
598        /// Full distribution over the declared levels.
599        probabilities: OrderedMap<f64>,
600        /// Certainty proxy; see [`CONFIDENCE_METHOD`].
601        confidence: f64,
602    },
603}
604
605#[derive(Debug, Serialize)]
606/// Token accounting for one request.
607pub struct Usage {
608    /// Sum of the per-question prompt lengths.
609    pub input_tokens: u64,
610    /// Always zero: the bridge reads log probabilities instead of generating.
611    pub output_tokens: u64,
612}
613
614/// The bridge's own extension block, mirroring fastjev's System One adapter.
615#[derive(Debug, Serialize)]
616pub struct FastjevMeta {
617    /// The uncalibrated-probability disclaimer.
618    pub probability_status: &'static str,
619    /// How `confidence` was derived.
620    pub confidence_method: &'static str,
621    /// Distinct prompt versions that produced this response.
622    pub prompt_versions: Vec<String>,
623    /// Whether the readout channel was validated on the served model.
624    pub readout: ReadoutStatus,
625}
626
627/// The documented System One response shape.
628#[derive(Debug, Serialize)]
629pub struct SystemOneResponse {
630    /// The served model id, echoed from the request.
631    pub model: String,
632    /// One answer per question id, in request order.
633    pub answers: OrderedMap<Answer>,
634    /// Token accounting.
635    pub usage: Usage,
636    /// Bridge-specific metadata.
637    pub fastjev: FastjevMeta,
638}
639
640/// Convert scorer results to the documented System One response shape.
641pub fn response_from_results(
642    served_model: &str,
643    specs: &[QuestionSpec],
644    results: &[ScoredAnswer],
645    readout: &ReadoutStatus,
646) -> Result<SystemOneResponse> {
647    if results.len() != specs.len() {
648        return Err(WireError(
649            "Scorer returned a different number of results than requested".to_string(),
650        ));
651    }
652    let mut answers = OrderedMap::new();
653    let mut input_tokens: u64 = 0;
654    let mut prompt_versions: Vec<String> = Vec::new();
655    for (spec, result) in specs.iter().zip(results) {
656        let probabilities = normalized_probabilities(spec, result)?;
657        let winner_index = probabilities
658            .iter()
659            .enumerate()
660            .fold(0usize, |best, (index, value)| {
661                if *value > probabilities[best] {
662                    index
663                } else {
664                    best
665                }
666            });
667        let mut distribution = OrderedMap::new();
668        for (option_id, value) in spec.option_ids.iter().zip(&probabilities) {
669            distribution.insert(option_id.clone(), *value);
670        }
671        let answer = match spec.kind {
672            Kind::Noul => {
673                let true_index = spec
674                    .option_ids
675                    .iter()
676                    .position(|option_id| option_id == "true")
677                    .ok_or_else(|| {
678                        WireError(format!("Scorer result for {:?} lost its true option", spec.id))
679                    })?;
680                Answer::Noul {
681                    noul: probabilities[true_index],
682                }
683            }
684            Kind::Choice => Answer::Choice {
685                choice: spec.option_ids[winner_index].clone(),
686                probabilities: distribution,
687                confidence: distribution_confidence(&probabilities),
688            },
689            Kind::Score => {
690                let score: f64 = probabilities
691                    .iter()
692                    .enumerate()
693                    .map(|(index, value)| index as f64 * value)
694                    .sum();
695                let mut legend: OrderedMap<String> = OrderedMap::new();
696                for (index, text) in spec.legend.iter().enumerate() {
697                    legend.insert(index.to_string(), text.clone());
698                }
699                Answer::Score {
700                    score,
701                    legend,
702                    probabilities: distribution,
703                    confidence: distribution_confidence(&probabilities),
704                }
705            }
706        };
707        answers.insert(spec.id.clone(), answer);
708        input_tokens += result.input_tokens;
709        if let Some(version) = &result.prompt_version
710            && !prompt_versions.contains(version)
711        {
712            prompt_versions.push(version.clone());
713        }
714    }
715    prompt_versions.sort();
716    Ok(SystemOneResponse {
717        model: served_model.to_string(),
718        answers,
719        usage: Usage {
720            input_tokens,
721            output_tokens: 0,
722        },
723        fastjev: FastjevMeta {
724            probability_status: PROBABILITY_STATUS,
725            confidence_method: CONFIDENCE_METHOD,
726            prompt_versions,
727            readout: readout.clone(),
728        },
729    })
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use serde_json::json;
736
737    fn sample_payload() -> Value {
738        json!({
739            "model": "bridge-model",
740            "state": "Help! My payouts have been failing for three days.",
741            "questions": {
742                "is_urgent": {"type": "noul", "instructions": "Does this convey urgency?"},
743                "department": {
744                    "type": "choice",
745                    "instructions": "Which team should handle this?",
746                    "criteria": {"billing": "Payments, invoicing, and refunds", "technical": null},
747                },
748                "severity": {
749                    "type": "score",
750                    "instructions": "How severe is the problem?",
751                    "criteria": ["Minor", "Degraded", "Blocking"],
752                },
753            },
754        })
755    }
756
757    #[test]
758    fn python_json_layout_matches_cpython_output() {
759        // The expected string is the exact stdout of CPython:
760        //   json.dumps(payload, ensure_ascii=False)
761        // for the same object, including nesting, booleans, null and non-ASCII.
762        let value = json!({
763            "evidence": {"message": "已扣款两次 charged twice", "count": 2, "tags": ["a", "b"],
764                         "ok": true, "missing": null, "ratio": 0.5},
765            "criterion": "Which queue?",
766            "options": [{"letter": "A", "description": "Billing, payments, and refunds."}],
767        });
768        assert_eq!(
769            to_python_json(&value),
770            r#"{"evidence": {"message": "已扣款两次 charged twice", "count": 2, "tags": ["a", "b"], "ok": true, "missing": null, "ratio": 0.5}, "criterion": "Which queue?", "options": [{"letter": "A", "description": "Billing, payments, and refunds."}]}"#
771        );
772    }
773
774    #[test]
775    fn python_json_layout_matches_python_separators() {
776        let value = json!({"a": [1, 2], "b": {"c": "x"}, "d": "中"});
777        assert_eq!(to_python_json(&value), r#"{"a": [1, 2], "b": {"c": "x"}, "d": "中"}"#);
778    }
779
780    #[test]
781    fn rows_render_descriptions_like_fastjev() {
782        let batch = request_batch(&sample_payload(), "bridge-model", false).unwrap();
783        assert_eq!(batch.specs.len(), 3);
784        assert_eq!(batch.questions[1].options[0].description, "billing: Payments, invoicing, and refunds");
785        assert_eq!(batch.questions[1].options[1].description, "technical");
786        assert_eq!(batch.questions[2].options[2].description, "Level 2: Blocking");
787        assert_eq!(batch.questions[0].options[0].description, "true: The answer is yes.");
788        assert_eq!(batch.specs[2].legend, vec!["Minor", "Degraded", "Blocking"]);
789        assert_eq!(batch.questions[1].question, "Which team should handle this?");
790        // One state, shared by every question rather than copied per question.
791        assert_eq!(batch.state, json!("Help! My payouts have been failing for three days."));
792    }
793
794    #[test]
795    fn noul_criteria_defaults_and_nulls_differ() {
796        let payload = json!({
797            "model": "m",
798            "state": "s",
799            "questions": {
800                "defaults": {"type": "noul"},
801                "explicit_null": {"type": "noul", "criteria": {"true": null, "false": "No way."}},
802            },
803        });
804        let batch = request_batch(&payload, "m", false).unwrap();
805        assert_eq!(batch.questions[0].options[0].description, "true: The answer is yes.");
806        assert_eq!(batch.questions[0].options[1].description, "false: The answer is no.");
807        assert_eq!(batch.questions[1].options[0].description, "true");
808        assert_eq!(batch.questions[1].options[1].description, "false: No way.");
809    }
810
811    #[test]
812    fn model_mismatch_is_rejected() {
813        let error = request_batch(&sample_payload(), "other-model", false).unwrap_err();
814        assert!(error.0.starts_with("model: "), "{}", error.0);
815    }
816
817    #[test]
818    fn empty_state_is_rejected() {
819        let payload = json!({"model": "m", "state": "", "questions": {"q": {"type": "noul"}}});
820        let error = request_batch(&payload, "m", false).unwrap_err();
821        assert!(error.0.starts_with("state: "), "{}", error.0);
822    }
823
824    #[test]
825    fn response_uses_probabilities_and_confidence() {
826        let batch = request_batch(&sample_payload(), "bridge-model", false).unwrap();
827        let results = vec![
828            ScoredAnswer {
829                id: "is_urgent".into(),
830                option_ids: vec!["true".into(), "false".into()],
831                probabilities: vec![0.9, 0.1],
832                input_tokens: 10,
833                prompt_version: Some("direct-options-v1".into()),
834            },
835            ScoredAnswer {
836                id: "department".into(),
837                option_ids: vec!["billing".into(), "technical".into()],
838                probabilities: vec![0.25, 0.75],
839                input_tokens: 20,
840                prompt_version: Some("direct-options-v1".into()),
841            },
842            ScoredAnswer {
843                id: "severity".into(),
844                option_ids: vec!["0".into(), "1".into(), "2".into()],
845                probabilities: vec![0.0, 1.0, 0.0],
846                input_tokens: 30,
847                prompt_version: Some("direct-options-v1".into()),
848            },
849        ];
850        let response = serde_json::to_value(
851            response_from_results("bridge-model", &batch.specs, &results, &ReadoutStatus::default())
852                .unwrap(),
853        )
854        .unwrap();
855        assert_eq!(response["answers"]["is_urgent"]["noul"], 0.9);
856        assert_eq!(response["answers"]["department"]["choice"], "technical");
857        assert_eq!(response["answers"]["severity"]["score"], 1.0);
858        assert_eq!(response["usage"]["input_tokens"], 60);
859        assert_eq!(response["usage"]["output_tokens"], 0);
860        assert_eq!(response["answers"]["severity"]["legend"]["2"], "Blocking");
861        let confidence = response["answers"]["severity"]["confidence"].as_f64().unwrap();
862        assert!((confidence - 1.0).abs() < 1e-12);
863    }
864
865    #[test]
866    fn the_response_carries_the_declared_readout_status() {
867        let batch = request_batch(&sample_payload(), "bridge-model", false).unwrap();
868        let results = vec![
869            ScoredAnswer {
870                id: "is_urgent".into(),
871                option_ids: vec!["true".into(), "false".into()],
872                probabilities: vec![0.9, 0.1],
873                input_tokens: 10,
874                prompt_version: Some("direct-options-v1".into()),
875            },
876            ScoredAnswer {
877                id: "department".into(),
878                option_ids: vec!["billing".into(), "technical".into()],
879                probabilities: vec![0.25, 0.75],
880                input_tokens: 20,
881                prompt_version: Some("direct-options-v1".into()),
882            },
883            ScoredAnswer {
884                id: "severity".into(),
885                option_ids: vec!["0".into(), "1".into(), "2".into()],
886                probabilities: vec![0.0, 1.0, 0.0],
887                input_tokens: 30,
888                prompt_version: Some("direct-options-v1".into()),
889            },
890        ];
891
892        // Default: unvalidated, with no evidence attached.
893        let response = serde_json::to_value(
894            response_from_results("bridge-model", &batch.specs, &results, &ReadoutStatus::default())
895                .unwrap(),
896        )
897        .unwrap();
898        assert_eq!(response["fastjev"]["readout"]["status"], UNVALIDATED_READOUT);
899        assert!(response["fastjev"]["readout"].get("evidence").is_none());
900
901        // Declared: the status and its evidence both travel with the response.
902        let declared = ReadoutStatus {
903            status: "validated".to_string(),
904            evidence: Some("argmax agreement 139/144".to_string()),
905        };
906        let response = serde_json::to_value(
907            response_from_results("bridge-model", &batch.specs, &results, &declared).unwrap(),
908        )
909        .unwrap();
910        assert_eq!(response["fastjev"]["readout"]["status"], "validated");
911        assert_eq!(
912            response["fastjev"]["readout"]["evidence"],
913            "argmax agreement 139/144"
914        );
915    }
916
917    #[test]
918    fn flat_distribution_has_zero_confidence() {
919        assert!(distribution_confidence(&[0.25, 0.25, 0.25, 0.25]).abs() < 1e-12);
920    }
921
922    #[test]
923    fn mismatched_result_is_rejected() {
924        let batch = request_batch(&sample_payload(), "bridge-model", false).unwrap();
925        let results = vec![ScoredAnswer {
926            id: "is_urgent".into(),
927            option_ids: vec!["false".into(), "true".into()],
928            probabilities: vec![0.5, 0.5],
929            input_tokens: 1,
930            prompt_version: None,
931        }];
932        assert!(
933            response_from_results("bridge-model", &batch.specs, &results, &ReadoutStatus::default())
934                .is_err()
935        );
936    }
937}