Skip to main content

laya/
common.rs

1//! Core model-independent logic: state serialization (Python `json.dumps` parity),
2//! option rendering, token-sequence construction, collation, and calibration helpers.
3//!
4//! Ported from `laya/common.py` (source of truth) and mirrored against `laya-ts/src/common.ts`.
5
6use crate::error::{LayaError, Result};
7use serde_json::Value;
8
9/// The three question types. Numeric indices match Python `QTYPES`
10/// (`choice=0, score=1, noul=2`) — they select the model's type embedding row.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum QType {
13    Choice,
14    Score,
15    Noul,
16}
17
18impl QType {
19    pub fn index(self) -> usize {
20        match self {
21            QType::Choice => 0,
22            QType::Score => 1,
23            QType::Noul => 2,
24        }
25    }
26
27    pub fn name(self) -> &'static str {
28        match self {
29            QType::Choice => "choice",
30            QType::Score => "score",
31            QType::Noul => "noul",
32        }
33    }
34
35    pub fn from_name(s: &str) -> Option<QType> {
36        match s {
37            "choice" => Some(QType::Choice),
38            "score" => Some(QType::Score),
39            "noul" => Some(QType::Noul),
40            _ => None,
41        }
42    }
43
44    pub fn from_index(i: usize) -> Option<QType> {
45        match i {
46            0 => Some(QType::Choice),
47            1 => Some(QType::Score),
48            2 => Some(QType::Noul),
49            _ => None,
50        }
51    }
52}
53
54/// A validated + normalized question, ready for sequence building and decoding.
55/// `crit` is kept as a JSON value in the shape the type expects (choice: object,
56/// score: array, noul: object-or-null), matching the normalization in `Agent::to_internal`.
57#[derive(Debug, Clone)]
58pub struct InternalQ {
59    pub t: QType,
60    pub ins: String,
61    pub crit: Value,
62    pub labels: Option<Value>,
63    /// Optional display order: slot `s` shows option `option_order[s]`. A permutation of
64    /// `range(n)` over the question's options, or `None` for the canonical order. Mirrors the
65    /// `option_order` key upstream `Agent._to_internal` stores.
66    pub option_order: Option<Vec<usize>>,
67}
68
69/// Abstraction over a checkpoint tokenizer. Mirrors `TokenizerLike` in laya-ts.
70/// `encode` corresponds to HF `tokenizer(text, add_special_tokens=False)["input_ids"]`.
71pub trait Tokenizer {
72    fn cls_id(&self) -> u32;
73    fn sep_id(&self) -> u32;
74    fn mask_id(&self) -> u32;
75    fn pad_id(&self) -> u32;
76    fn mask_token(&self) -> &str;
77    fn encode(&self, text: &str) -> Vec<u32>;
78}
79
80const DEFAULT_NOUL_FALSE: &str = "false";
81const DEFAULT_NOUL_TRUE: &str = "true";
82
83/// Replica of Python `json.dumps(value, ensure_ascii=False, separators=(", ", ": "))`.
84///
85/// This differs from `serde_json::to_string` (which uses no spaces after `,`/`:`); the exact
86/// spacing matters because serialized states/criteria are tokenized, so a spacing difference
87/// changes token ids. Mirrors `pyJson` in `laya-ts/src/common.ts`.
88pub fn py_json(v: &Value) -> String {
89    match v {
90        Value::Null => "null".to_string(),
91        Value::Bool(b) => {
92            if *b {
93                "true".to_string()
94            } else {
95                "false".to_string()
96            }
97        }
98        Value::Number(n) => n.to_string(),
99        // serde_json escapes control chars / `"` / `\` and keeps non-ASCII raw — same as
100        // Python `ensure_ascii=False`.
101        Value::String(_) => serde_json::to_string(v).unwrap_or_else(|_| "\"\"".to_string()),
102        Value::Array(a) => {
103            let parts: Vec<String> = a.iter().map(py_json).collect();
104            format!("[{}]", parts.join(", "))
105        }
106        Value::Object(m) => {
107            let parts: Vec<String> = m
108                .iter()
109                .map(|(k, val)| {
110                    let key = serde_json::to_string(&Value::String(k.clone()))
111                        .unwrap_or_else(|_| "\"\"".to_string());
112                    format!("{}: {}", key, py_json(val))
113                })
114                .collect();
115            format!("{{{}}}", parts.join(", "))
116        }
117    }
118}
119
120/// A state as the model sees it: raw strings pass through, everything else becomes compact JSON.
121pub fn serialize_state(state: &Value) -> String {
122    match state {
123        Value::String(s) => s.clone(),
124        other => py_json(other),
125    }
126}
127
128/// Render one criterion value as text (strings pass through, structured values become JSON).
129pub fn render_criterion(v: &Value) -> String {
130    match v {
131        Value::String(s) => s.clone(),
132        other => py_json(other),
133    }
134}
135
136/// True when a criterion value means "no description" (only `null`/`""`; `0`/`false` are real).
137fn is_empty_criterion(v: Option<&Value>) -> bool {
138    match v {
139        None => true,
140        Some(Value::Null) => true,
141        Some(Value::String(s)) if s.is_empty() => true,
142        _ => false,
143    }
144}
145
146/// Resolve noul labels to `(false_label, true_label)`, validating the mapping.
147pub fn resolve_noul_labels(labels: Option<&Value>) -> Result<(String, String)> {
148    let err = || {
149        LayaError::InvalidQuestion(
150            "noul labels must map exactly 'false' and 'true' to distinct non-empty strings"
151                .to_string(),
152        )
153    };
154    let (f, t) = match labels {
155        None | Some(Value::Null) => {
156            return Ok((
157                DEFAULT_NOUL_FALSE.to_string(),
158                DEFAULT_NOUL_TRUE.to_string(),
159            ))
160        }
161        Some(Value::Object(m)) => {
162            let keys: std::collections::BTreeSet<&str> = m.keys().map(|s| s.as_str()).collect();
163            let want: std::collections::BTreeSet<&str> = ["false", "true"].into_iter().collect();
164            if keys != want {
165                return Err(err());
166            }
167            (m.get("false"), m.get("true"))
168        }
169        _ => return Err(err()),
170    };
171    let (f, t) = match (f, t) {
172        (Some(Value::String(f)), Some(Value::String(t))) => (f, t),
173        _ => return Err(err()),
174    };
175    let f = f.trim();
176    let t = t.trim();
177    if f.is_empty() || t.is_empty() || f == t {
178        return Err(err());
179    }
180    Ok((f.to_string(), t.to_string()))
181}
182
183/// Render option texts in label-index order. Noul semantic order is always `[false, true]`.
184pub fn render_options(q: &InternalQ) -> Result<Vec<String>> {
185    if q.t != QType::Noul && q.labels.is_some() {
186        return Err(LayaError::InvalidQuestion(
187            "labels is only supported for noul questions".to_string(),
188        ));
189    }
190    match q.t {
191        QType::Choice => {
192            let obj = q.crit.as_object().ok_or_else(|| {
193                LayaError::InvalidQuestion("choice criteria must be an object".to_string())
194            })?;
195            Ok(obj
196                .iter()
197                .map(|(k, v)| {
198                    if is_empty_criterion(Some(v)) {
199                        k.clone()
200                    } else {
201                        format!("{}: {}", k, render_criterion(v))
202                    }
203                })
204                .collect())
205        }
206        QType::Score => {
207            let arr = q.crit.as_array().ok_or_else(|| {
208                LayaError::InvalidQuestion("score criteria must be a list".to_string())
209            })?;
210            Ok(arr
211                .iter()
212                .enumerate()
213                .map(|(i, c)| format!("level {}: {}", i, render_criterion(c)))
214                .collect())
215        }
216        QType::Noul => {
217            let (false_label, true_label) = resolve_noul_labels(q.labels.as_ref())?;
218            let empty = serde_json::Map::new();
219            let crit = q.crit.as_object().unwrap_or(&empty);
220            let false_crit = crit.get("false");
221            let true_crit = crit.get("true");
222            let false_text = if is_empty_criterion(false_crit) {
223                "no, the statement does not hold".to_string()
224            } else {
225                render_criterion(false_crit.unwrap())
226            };
227            let true_text = if is_empty_criterion(true_crit) {
228                "yes, the statement holds".to_string()
229            } else {
230                render_criterion(true_crit.unwrap())
231            };
232            Ok(vec![
233                format!("{}: {}", false_label, false_text),
234                format!("{}: {}", true_label, true_text),
235            ])
236        }
237    }
238}
239
240/// Build one token sequence + option marker positions.
241///
242/// Format: `[CLS] <type> question: <instructions> [SEP] [MASK] opt0 [MASK] opt1 ... [SEP] state [SEP]`.
243/// Ported verbatim from `build_sequence` in `laya/common.py`.
244pub fn build_sequence<T: Tokenizer + ?Sized>(
245    tok: &T,
246    state: &Value,
247    q: &InternalQ,
248    max_len: usize,
249    head_max_len: usize,
250    option_order: Option<&[usize]>,
251    truncate_left: bool,
252) -> Result<(Vec<u32>, Vec<usize>)> {
253    let mask_tok = tok.mask_token();
254    let opts = render_options(q)?;
255    let order: Vec<usize> = match option_order {
256        Some(o) => o.to_vec(),
257        None => (0..opts.len()).collect(),
258    };
259
260    let ins = q.ins.replace(mask_tok, " ");
261    let head_text = format!("{} question: {}", q.t.name(), ins);
262    let mut head_ids = tok.encode(&head_text);
263
264    let mut opt_ids: Vec<Vec<u32>> = Vec::with_capacity(order.len());
265    for &i in &order {
266        let mut o = vec![tok.mask_id()];
267        let enc = tok.encode(&format!(" {}", opts[i].replace(mask_tok, " ")));
268        o.extend(enc.into_iter().take(48));
269        opt_ids.push(o);
270    }
271
272    let sum_opt = |v: &[Vec<u32>]| -> usize { v.iter().map(|o| o.len()).sum() };
273    let mut opt_budget: isize = head_max_len as isize - sum_opt(&opt_ids) as isize;
274    if opt_budget < 16 {
275        let denom = std::cmp::max(1, opt_ids.len()) as isize;
276        let per = std::cmp::max(4, (head_max_len as isize - 16) / denom) as usize;
277        for o in opt_ids.iter_mut() {
278            o.truncate(per);
279        }
280        opt_budget = head_max_len as isize - sum_opt(&opt_ids) as isize;
281    }
282    let head_keep = opt_budget.max(8) as usize;
283    head_ids.truncate(head_keep.min(head_ids.len()));
284
285    let mut ids: Vec<u32> = Vec::new();
286    ids.push(tok.cls_id());
287    ids.extend_from_slice(&head_ids);
288    ids.push(tok.sep_id());
289
290    let mut markers: Vec<usize> = Vec::with_capacity(opt_ids.len());
291    for o in &opt_ids {
292        markers.push(ids.len());
293        ids.extend_from_slice(o);
294    }
295    ids.push(tok.sep_id());
296
297    let room = (max_len as isize - ids.len() as isize - 1).max(0) as usize;
298    let st_all = tok.encode(&serialize_state(state).replace(mask_tok, " "));
299    let st: Vec<u32> = if truncate_left {
300        let start = st_all.len().saturating_sub(room);
301        st_all[start..].to_vec()
302    } else {
303        st_all.into_iter().take(room).collect()
304    };
305    ids.extend_from_slice(&st);
306    ids.push(tok.sep_id());
307    ids.truncate(max_len);
308
309    let markers = markers.into_iter().filter(|&m| m < max_len).collect();
310    Ok((ids, markers))
311}
312
313/// Round to `ndigits` decimals using round-half-to-even, matching Python's built-in `round`.
314pub fn round_half_even(x: f64, ndigits: i32) -> f64 {
315    if !x.is_finite() {
316        return x;
317    }
318    let factor = 10f64.powi(ndigits);
319    let scaled = x * factor;
320    let floor = scaled.floor();
321    let diff = scaled - floor;
322    let rounded = if (diff - 0.5).abs() < 1e-9 {
323        // exact half → round to even
324        if (floor as i64) % 2 == 0 {
325            floor
326        } else {
327            floor + 1.0
328        }
329    } else {
330        scaled.round()
331    };
332    rounded / factor
333}
334
335/// Round to 4 decimals like Python `round(x, 4)` (the precision Laya publishes).
336pub fn round4(x: f64) -> f64 {
337    round_half_even(x, 4)
338}
339
340/// Numerically stable softmax.
341pub fn softmax(z: &[f64]) -> Vec<f64> {
342    if z.is_empty() {
343        return Vec::new();
344    }
345    let m = z.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
346    let exps: Vec<f64> = z.iter().map(|&v| (v - m).exp()).collect();
347    let sum: f64 = exps.iter().sum();
348    exps.into_iter().map(|v| v / sum).collect()
349}
350
351/// Probability mass on the answer being reported: `max(p[:k])`, clamped to `[0, 1]`.
352///
353/// This is the quantity temperature scaling fits and every calibration figure is computed on
354/// (`conf = max(probs)`), so it is the one confidence with the calibration guarantee the gating
355/// docs rely on. [`confidence_from_probs`] reports a different quantity on a different scale and
356/// carries no such guarantee, so the two must not be compared against the same threshold.
357pub fn answer_confidence(p: &[f64], k: usize) -> f64 {
358    if k < 1 {
359        return 1.0;
360    }
361    let p = &p[..k.min(p.len())];
362    let max = p.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
363    max.clamp(0.0, 1.0)
364}
365
366/// Put a slot-ordered probability row back into the caller's option order.
367///
368/// [`build_sequence`] puts option `option_order[s]` in slot `s`, so a model row comes back indexed
369/// by slot. Everything downstream indexes by option (`zip(keys, p)` for a choice, `i * p` for a
370/// score level, `p[1]` for noul-true), so the row has to be inverted first or the probabilities end
371/// up attached to the wrong options, which is silent. A missing or mismatched order returns `p`
372/// unchanged, so the canonical path is untouched. Mirrors `unpermute_probs` in `laya/common.py`.
373pub fn unpermute_probs(p: &[f64], option_order: Option<&[usize]>) -> Vec<f64> {
374    match option_order {
375        Some(order) if order.len() == p.len() => {
376            let mut canonical = vec![0.0; p.len()];
377            for (slot, &opt) in order.iter().enumerate() {
378                canonical[opt] = p[slot];
379            }
380            canonical
381        }
382        _ => p.to_vec(),
383    }
384}
385
386/// Normalized Shannon-entropy confidence: `1 - H(p)/log(k)`, clamped to `[0, 1]`.
387/// `p` is expected to already hold the `k` option probabilities.
388pub fn confidence_from_probs(p: &[f64], k: usize) -> f64 {
389    if k < 2 {
390        return 1.0;
391    }
392    let p = &p[..k.min(p.len())];
393    let ent: f64 = -p.iter().map(|&v| v * v.clamp(1e-12, 1.0).ln()).sum::<f64>();
394    (1.0 - ent / (k as f64).ln()).clamp(0.0, 1.0)
395}
396
397/// Jev's `confidence` for a `choice`/`score` answer's `probabilities`, or `None` (upstream #302).
398///
399/// Laya's own `confidence` is normalized entropy ([`confidence_from_probs`]); Jev's is a different
400/// formula, so a threshold carried over from Jev selects about twice as much on Laya. These are
401/// TypeSafe's own formulas (`system-one-adapter-python`, `_utils/confidence_metrics.py`), mirrored
402/// from upstream `laya/confidence.py::jev_confidence`:
403///
404/// - `choice`: `(max(p) - 1/n) / (1 - 1/n)`
405/// - `score`: `max(0, 1 - sum(p_i * |i - mode|) / D)`, `D` the same sum for a uniform distribution
406///
407/// Both are `1.0` for a single option, and a zero total is read as uniform. A `noul` answer has no
408/// Jev confidence, and malformed probabilities (missing, empty, non-numeric, boolean, non-finite,
409/// negative, or — for `score` — a non-integer level key) give `None` rather than a guess, so a
410/// caller filtering on this number never filters on a made-up one. Negatives are refused because
411/// the formulas normalise by the sum: a mixed-sign document can divide by a near-zero or negative
412/// total and return a number outside `[0, 1]` for a distribution whose argmax is not the one it
413/// measured. Refusing them makes the range a property — every number this returns lies in `[0, 1]`
414/// (upstream `confidence.py::jev_confidence`).
415pub fn jev_confidence(answer: &Value) -> Option<f64> {
416    let kind = answer.get("type").and_then(|v| v.as_str())?;
417    let probs = answer.get("probabilities").and_then(|v| v.as_object())?;
418    if probs.is_empty() || !matches!(kind, "choice" | "score") {
419        return None;
420    }
421    // Reject a boolean before reading it as a number: Python drops `isinstance(v, bool)` even
422    // though `float(True)` would succeed, so a bool probability yields no confidence.
423    if probs.values().any(|v| v.is_boolean()) {
424        return None;
425    }
426    let p: Vec<f64> = if kind == "score" {
427        // Levels are read in numeric order, not string order ("10" sorts before "2" as text); a
428        // non-integer key is refused, matching Python's `sorted(probabilities, key=int)`.
429        let mut keyed: Vec<(i64, f64)> = Vec::with_capacity(probs.len());
430        for (key, value) in probs {
431            let idx = key.parse::<i64>().ok()?;
432            keyed.push((idx, value.as_f64()?));
433        }
434        keyed.sort_by_key(|&(idx, _)| idx);
435        keyed.into_iter().map(|(_, v)| v).collect()
436    } else {
437        probs.values().map(|v| v.as_f64()).collect::<Option<_>>()?
438    };
439    if !p.iter().all(|v| v.is_finite() && *v >= 0.0) {
440        return None;
441    }
442    let n = p.len();
443    if n == 1 {
444        return Some(1.0);
445    }
446    let total: f64 = p.iter().sum();
447    let p: Vec<f64> = if total != 0.0 {
448        p.iter().map(|&v| v / total).collect()
449    } else {
450        vec![1.0 / n as f64; n]
451    };
452    if kind == "choice" {
453        let max = p.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
454        return Some((max - 1.0 / n as f64) / (1.0 - 1.0 / n as f64));
455    }
456    // score: distance of the distribution from its mode, scaled by a uniform distribution's.
457    let mode = (0..n)
458        .max_by(|&a, &b| p[a].partial_cmp(&p[b]).unwrap())
459        .unwrap();
460    let spread: f64 = p
461        .iter()
462        .enumerate()
463        .map(|(i, &v)| v * (i as f64 - mode as f64).abs())
464        .sum();
465    let center = (n as f64 - 1.0) / 2.0;
466    let uniform_spread: f64 = (0..n).map(|i| (i as f64 - center).abs()).sum::<f64>() / n as f64;
467    Some((1.0 - spread / uniform_spread).max(0.0))
468}
469
470/// Remove an `option_order` a narrowed choice question can no longer honour, warning once per
471/// `qid`. Returns `true` if an order was dropped. Mirrors upstream `shortlist._drop_stale_order`.
472///
473/// `option_order` holds one slot per option, so an order that fit the caller's label set does not
474/// fit a narrowed one and the agent refuses the question. Remapping the order onto the kept labels
475/// would silently move options between slots, so it is dropped instead, once per question per call.
476/// Rust has no `warnings` module, so the one-shot notice goes to stderr like the crate's other
477/// diagnostics (`agent.rs`); `warned` tracks which questions have already been reported.
478pub fn drop_stale_option_order(
479    qid: &str,
480    question: &mut Value,
481    n_options: usize,
482    warned: &mut std::collections::HashSet<String>,
483) -> bool {
484    let order = match question
485        .as_object_mut()
486        .and_then(|m| m.remove("option_order"))
487    {
488        Some(o) => o,
489        None => return false,
490    };
491    if warned.insert(qid.to_string()) {
492        let had = match &order {
493            Value::Array(a) => format!("{} slots", a.len()),
494            other => py_json(other),
495        };
496        eprintln!(
497            "laya.shortlist: question {:?}: 'option_order' ({}) was dropped because the label set \
498             was narrowed to {}; the order no longer fits the kept options and remapping it onto \
499             them would move options between slots.",
500            qid, had, n_options
501        );
502    }
503    true
504}
505
506pub const TEMP_MIN: f64 = 0.5;
507pub const TEMP_MAX: f64 = 5.0;
508
509/// A usable temperature: `t` confined to `[TEMP_MIN, TEMP_MAX]`, falling back to 1.0 if not finite.
510pub fn clamp_temperature(t: &Value) -> f64 {
511    let f = match t {
512        Value::Number(n) => n.as_f64(),
513        Value::String(s) => s.trim().parse::<f64>().ok(),
514        _ => None,
515    };
516    match f {
517        Some(x) if x.is_finite() => x.clamp(TEMP_MIN, TEMP_MAX),
518        _ => 1.0,
519    }
520}
521
522/// Temperature bucket key, e.g. `"choice:3-5"`.
523pub fn temp_bucket(qtype: usize, k: usize) -> String {
524    let size = if k <= 2 {
525        "2"
526    } else if k <= 5 {
527        "3-5"
528    } else if k <= 10 {
529        "6-10"
530    } else {
531        "11+"
532    };
533    let name = QType::from_index(qtype)
534        .map(|q| q.name())
535        .unwrap_or("choice");
536    format!("{}:{}", name, size)
537}
538
539/// One tokenized (state, question) pair before collation.
540#[derive(Debug, Clone)]
541pub struct Item {
542    pub ids: Vec<u32>,
543    pub markers: Vec<usize>,
544    pub qtype: usize,
545}
546
547/// A padded batch ready to be turned into tensors.
548#[derive(Debug, Clone)]
549pub struct CollatedBatch {
550    pub input_ids: Vec<Vec<u32>>,
551    pub attention_mask: Vec<Vec<u32>>,
552    pub marker_pos: Vec<Vec<usize>>,
553    pub marker_mask: Vec<Vec<bool>>,
554    pub qtype: Vec<usize>,
555}
556
557/// Collate groups of items into one padded batch. `groups` is a list of per-state item lists,
558/// flattened in order (mirrors `collate_items` in `laya/common.py`). Returns `None` if empty.
559pub fn collate_items(groups: &[Vec<Item>], _pad_id: u32) -> Option<CollatedBatch> {
560    let items: Vec<&Item> = groups.iter().flatten().collect();
561    if items.is_empty() {
562        return None;
563    }
564    let l = items.iter().map(|it| it.ids.len()).max().unwrap();
565    let kmax = items.iter().map(|it| it.markers.len()).max().unwrap();
566
567    let mut input_ids = Vec::with_capacity(items.len());
568    let mut attention_mask = Vec::with_capacity(items.len());
569    let mut marker_pos = Vec::with_capacity(items.len());
570    let mut marker_mask = Vec::with_capacity(items.len());
571    let mut qtype = Vec::with_capacity(items.len());
572
573    for it in &items {
574        let mut ids = it.ids.clone();
575        let mut att = vec![1u32; ids.len()];
576        ids.resize(l, _pad_id);
577        att.resize(l, 0);
578        input_ids.push(ids);
579        attention_mask.push(att);
580
581        let k = it.markers.len();
582        let mut mpos: Vec<usize> = it.markers.clone();
583        mpos.resize(kmax, 0);
584        let mut mmask: Vec<bool> = vec![true; k];
585        mmask.resize(kmax, false);
586        marker_pos.push(mpos);
587        marker_mask.push(mmask);
588
589        qtype.push(it.qtype);
590    }
591
592    Some(CollatedBatch {
593        input_ids,
594        attention_mask,
595        marker_pos,
596        marker_mask,
597        qtype,
598    })
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use serde_json::json;
605
606    /// A deterministic fake tokenizer for structural tests (one id per whitespace word).
607    struct Fake;
608    impl Tokenizer for Fake {
609        fn cls_id(&self) -> u32 {
610            1
611        }
612        fn sep_id(&self) -> u32 {
613            2
614        }
615        fn mask_id(&self) -> u32 {
616            3
617        }
618        fn pad_id(&self) -> u32 {
619            0
620        }
621        fn mask_token(&self) -> &str {
622            "[MASK]"
623        }
624        fn encode(&self, text: &str) -> Vec<u32> {
625            text.split_whitespace()
626                .map(|w| 100 + (w.len() as u32))
627                .collect()
628        }
629    }
630
631    #[test]
632    fn py_json_matches_python_spacing() {
633        // Python json.dumps({"a":1,"b":"x"}, ensure_ascii=False) -> '{"a": 1, "b": "x"}'
634        let v = json!({"a": 1, "b": "x"});
635        assert_eq!(py_json(&v), r#"{"a": 1, "b": "x"}"#);
636        assert_eq!(py_json(&json!([1, 2, 3])), "[1, 2, 3]");
637        assert_eq!(py_json(&json!("café")), "\"café\""); // non-ASCII stays raw
638        assert_eq!(py_json(&json!(true)), "true");
639        assert_eq!(py_json(&json!(null)), "null");
640    }
641
642    #[test]
643    fn serialize_state_passes_strings_through() {
644        assert_eq!(serialize_state(&json!("hello")), "hello");
645        assert_eq!(serialize_state(&json!({"k": "v"})), r#"{"k": "v"}"#);
646    }
647
648    #[test]
649    fn answer_confidence_is_max_prob() {
650        // max(p[:k]), clamped to [0, 1]; k < 1 is defined as fully confident.
651        assert_eq!(answer_confidence(&[0.2, 0.7, 0.1], 3), 0.7);
652        assert_eq!(answer_confidence(&[0.6, 0.4], 2), 0.6);
653        assert_eq!(answer_confidence(&[0.9], 1), 0.9);
654        assert_eq!(answer_confidence(&[], 0), 1.0);
655        // Only the first k entries count.
656        assert_eq!(answer_confidence(&[0.3, 0.3, 0.99], 2), 0.3);
657    }
658
659    #[test]
660    fn round4_half_to_even() {
661        assert_eq!(round4(0.12345), 0.1234); // 0.12345 -> 0.1234 (round half to even)
662        assert_eq!(round4(0.12355), 0.1236);
663        assert_eq!(round4(1.0), 1.0);
664    }
665
666    #[test]
667    fn render_options_per_type() {
668        let choice = InternalQ {
669            t: QType::Choice,
670            ins: String::new(),
671            crit: json!({"a": "first", "b": null, "c": ""}),
672            labels: None,
673            option_order: None,
674        };
675        assert_eq!(render_options(&choice).unwrap(), vec!["a: first", "b", "c"]);
676
677        let score = InternalQ {
678            t: QType::Score,
679            ins: String::new(),
680            crit: json!(["low", "high"]),
681            labels: None,
682            option_order: None,
683        };
684        assert_eq!(
685            render_options(&score).unwrap(),
686            vec!["level 0: low", "level 1: high"]
687        );
688
689        let noul = InternalQ {
690            t: QType::Noul,
691            ins: String::new(),
692            crit: json!({"true": "yes it does", "false": "no"}),
693            labels: None,
694            option_order: None,
695        };
696        assert_eq!(
697            render_options(&noul).unwrap(),
698            vec!["false: no", "true: yes it does"]
699        );
700
701        let noul_default = InternalQ {
702            t: QType::Noul,
703            ins: String::new(),
704            crit: Value::Null,
705            labels: None,
706            option_order: None,
707        };
708        assert_eq!(
709            render_options(&noul_default).unwrap(),
710            vec![
711                "false: no, the statement does not hold",
712                "true: yes, the statement holds"
713            ]
714        );
715    }
716
717    #[test]
718    fn temp_bucket_sizes() {
719        assert_eq!(temp_bucket(0, 2), "choice:2");
720        assert_eq!(temp_bucket(1, 4), "score:3-5");
721        assert_eq!(temp_bucket(2, 8), "noul:6-10");
722        assert_eq!(temp_bucket(0, 20), "choice:11+");
723    }
724
725    #[test]
726    fn build_sequence_structure() {
727        let q = InternalQ {
728            t: QType::Choice,
729            ins: "pick one".to_string(),
730            crit: json!({"a": null, "b": null}),
731            labels: None,
732            option_order: None,
733        };
734        let (ids, markers) =
735            build_sequence(&Fake, &json!("some state text"), &q, 512, 192, None, false).unwrap();
736        assert_eq!(ids[0], 1); // [CLS]
737        assert_eq!(*ids.last().unwrap(), 2); // trailing [SEP]
738        assert_eq!(markers.len(), 2); // one marker per option
739                                      // each marker points at a [MASK] id
740        for &m in &markers {
741            assert_eq!(ids[m], 3);
742        }
743    }
744
745    #[test]
746    fn confidence_bounds() {
747        assert_eq!(confidence_from_probs(&[1.0], 1), 1.0); // k<2
748        let uniform = confidence_from_probs(&[0.5, 0.5], 2);
749        assert!(uniform.abs() < 1e-9); // max entropy -> confidence 0
750        let certain = confidence_from_probs(&[1.0, 0.0], 2);
751        assert!((certain - 1.0).abs() < 1e-9);
752    }
753
754    #[test]
755    fn unpermute_inverts_slot_order() {
756        // Slot 0 shows option 2, slot 1 shows option 0, slot 2 shows option 1. The row is indexed
757        // by slot; unpermuting attaches each slot's mass to the option it showed.
758        let p = [0.7, 0.2, 0.1];
759        let order = [2usize, 0, 1];
760        assert_eq!(unpermute_probs(&p, Some(&order)), vec![0.2, 0.1, 0.7]);
761        // No order, or a length mismatch, leaves the row untouched.
762        assert_eq!(unpermute_probs(&p, None), vec![0.7, 0.2, 0.1]);
763        assert_eq!(unpermute_probs(&p, Some(&[0usize, 1])), vec![0.7, 0.2, 0.1]);
764    }
765
766    #[test]
767    fn build_sequence_honours_option_order() {
768        // With options aa, b, ccc and order [2, 1, 0], the option block runs ccc, b, aa. The Fake
769        // tokenizer maps a word to `100 + len`, so options of distinct lengths land on distinct ids
770        // and the reordered block is observable.
771        let q = InternalQ {
772            t: QType::Choice,
773            ins: "pick one".to_string(),
774            crit: json!({"aa": null, "b": null, "ccc": null}),
775            labels: None,
776            option_order: Some(vec![2, 1, 0]),
777        };
778        let (ids, markers) = build_sequence(
779            &Fake,
780            &json!("state"),
781            &q,
782            512,
783            192,
784            Some(&[2, 1, 0]),
785            false,
786        )
787        .unwrap();
788        assert_eq!(markers.len(), 3);
789        // The token right after each [MASK] marker is the shown option's word: slot 0 shows ccc
790        // (id 103) and slot 2 shows aa (id 102), so the two ends differ.
791        assert_eq!(ids[markers[0] + 1], 103);
792        assert_eq!(ids[markers[2] + 1], 102);
793    }
794
795    fn choice_answer(p: &[f64]) -> Value {
796        let probs: serde_json::Map<String, Value> = p
797            .iter()
798            .enumerate()
799            .map(|(i, &v)| (format!("o{i}"), json!(v)))
800            .collect();
801        json!({ "type": "choice", "probabilities": probs })
802    }
803
804    fn score_answer(p: &[f64]) -> Value {
805        let probs: serde_json::Map<String, Value> = p
806            .iter()
807            .enumerate()
808            .map(|(i, &v)| (i.to_string(), json!(v)))
809            .collect();
810        json!({ "type": "score", "probabilities": probs })
811    }
812
813    #[test]
814    fn jev_confidence_matches_typesafe_vectors() {
815        // Vectors from upstream tests/test_confidence.py (TypeSafe's own + the jevcompat spec).
816        let cases: &[(Value, f64)] = &[
817            (choice_answer(&[0.5, 0.5]), 0.0),
818            (choice_answer(&[0.2, 0.2]), 0.0), // unnormalized but uniform -> 0
819            (choice_answer(&[0.0, 0.0]), 0.0), // zero total read as uniform
820            (choice_answer(&[0.82, 0.18]), 0.64),
821            (choice_answer(&[1.0]), 1.0),
822            (choice_answer(&[0.88, 0.12]), 0.76),
823            (score_answer(&[0.2, 0.2, 0.2, 0.2, 0.2]), 0.0),
824            (score_answer(&[0.04, 0.04, 0.04, 0.04, 0.04]), 0.0),
825            (score_answer(&[0.01, 0.02, 0.07, 0.3, 0.6]), 0.55),
826            (score_answer(&[1.0]), 1.0),
827            (score_answer(&[0.0, 0.95, 0.05]), 0.925),
828        ];
829        for (answer, want) in cases {
830            let got = jev_confidence(answer).expect("a usable confidence");
831            assert!(
832                (got - want).abs() < 1e-9,
833                "answer {answer:?}: got {got}, want {want}"
834            );
835        }
836    }
837
838    #[test]
839    fn jev_confidence_reads_score_levels_in_numeric_order() {
840        // "10" sorts before "2" as text; the mode must still be level 10.
841        let mut probs = serde_json::Map::new();
842        for i in 0..=10 {
843            probs.insert(i.to_string(), json!(0.0));
844        }
845        probs.insert("10".to_string(), json!(1.0));
846        let answer = json!({ "type": "score", "probabilities": probs });
847        assert_eq!(jev_confidence(&answer), Some(1.0));
848    }
849
850    #[test]
851    fn jev_confidence_differs_from_entropy() {
852        // The same distribution #302 measured apart: Jev 0.6, entropy below 0.35.
853        let p = [0.7, 0.1, 0.1, 0.1];
854        let jev = jev_confidence(&choice_answer(&p)).unwrap();
855        assert!((jev - 0.6).abs() < 1e-9, "jev {jev}");
856        assert!(confidence_from_probs(&p, 4) < 0.35);
857    }
858
859    #[test]
860    fn jev_confidence_none_for_unusable_answers() {
861        let none_cases = [
862            json!({ "type": "noul", "noul": 0.9 }),
863            json!({ "type": "weird", "probabilities": { "a": 1.0 } }),
864            json!({ "type": "choice" }),
865            json!({ "type": "choice", "probabilities": {} }),
866            json!({ "type": "choice", "probabilities": { "o0": 0.5, "o1": "high" } }),
867            json!({ "type": "choice", "probabilities": { "o0": true, "o1": false } }),
868            json!({ "type": "score", "probabilities": { "low": 0.5, "high": 0.5 } }),
869            // A negative probability is refused rather than normalised into range
870            // (upstream 0.4.8 `confidence.py`).
871            json!({ "type": "choice", "probabilities": { "o0": 1.5, "o1": -0.5 } }),
872            json!({ "type": "score", "probabilities": { "0": 0.5, "1": -0.1, "2": 0.6 } }),
873        ];
874        for answer in none_cases {
875            assert_eq!(jev_confidence(&answer), None, "answer {answer:?}");
876        }
877    }
878
879    #[test]
880    fn drop_stale_option_order_drops_and_dedupes() {
881        use std::collections::HashSet;
882        let mut warned = HashSet::new();
883        let mut q =
884            json!({ "type": "choice", "criteria": ["a", "b"], "option_order": [3, 2, 1, 0] });
885        assert!(drop_stale_option_order("intent", &mut q, 2, &mut warned));
886        assert!(q.get("option_order").is_none(), "stale order removed");
887        assert_eq!(
888            q.get("criteria"),
889            Some(&json!(["a", "b"])),
890            "criteria untouched"
891        );
892        assert!(warned.contains("intent"));
893        // A question with no order is left alone and reports nothing.
894        let mut plain = json!({ "type": "choice", "criteria": ["a", "b"] });
895        assert!(!drop_stale_option_order(
896            "intent",
897            &mut plain,
898            2,
899            &mut warned
900        ));
901        assert_eq!(warned.len(), 1, "no new warning for an order-free question");
902    }
903}