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/// or — for `score` — a non-integer level key) give `None` rather than a guess, so a caller
410/// filtering on this number never filters on a made-up one.
411pub fn jev_confidence(answer: &Value) -> Option<f64> {
412    let kind = answer.get("type").and_then(|v| v.as_str())?;
413    let probs = answer.get("probabilities").and_then(|v| v.as_object())?;
414    if probs.is_empty() || !matches!(kind, "choice" | "score") {
415        return None;
416    }
417    // Reject a boolean before reading it as a number: Python drops `isinstance(v, bool)` even
418    // though `float(True)` would succeed, so a bool probability yields no confidence.
419    if probs.values().any(|v| v.is_boolean()) {
420        return None;
421    }
422    let p: Vec<f64> = if kind == "score" {
423        // Levels are read in numeric order, not string order ("10" sorts before "2" as text); a
424        // non-integer key is refused, matching Python's `sorted(probabilities, key=int)`.
425        let mut keyed: Vec<(i64, f64)> = Vec::with_capacity(probs.len());
426        for (key, value) in probs {
427            let idx = key.parse::<i64>().ok()?;
428            keyed.push((idx, value.as_f64()?));
429        }
430        keyed.sort_by_key(|&(idx, _)| idx);
431        keyed.into_iter().map(|(_, v)| v).collect()
432    } else {
433        probs.values().map(|v| v.as_f64()).collect::<Option<_>>()?
434    };
435    if !p.iter().all(|v| v.is_finite()) {
436        return None;
437    }
438    let n = p.len();
439    if n == 1 {
440        return Some(1.0);
441    }
442    let total: f64 = p.iter().sum();
443    let p: Vec<f64> = if total != 0.0 {
444        p.iter().map(|&v| v / total).collect()
445    } else {
446        vec![1.0 / n as f64; n]
447    };
448    if kind == "choice" {
449        let max = p.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
450        return Some((max - 1.0 / n as f64) / (1.0 - 1.0 / n as f64));
451    }
452    // score: distance of the distribution from its mode, scaled by a uniform distribution's.
453    let mode = (0..n)
454        .max_by(|&a, &b| p[a].partial_cmp(&p[b]).unwrap())
455        .unwrap();
456    let spread: f64 = p
457        .iter()
458        .enumerate()
459        .map(|(i, &v)| v * (i as f64 - mode as f64).abs())
460        .sum();
461    let center = (n as f64 - 1.0) / 2.0;
462    let uniform_spread: f64 = (0..n).map(|i| (i as f64 - center).abs()).sum::<f64>() / n as f64;
463    Some((1.0 - spread / uniform_spread).max(0.0))
464}
465
466/// Remove an `option_order` a narrowed choice question can no longer honour, warning once per
467/// `qid`. Returns `true` if an order was dropped. Mirrors upstream `shortlist._drop_stale_order`.
468///
469/// `option_order` holds one slot per option, so an order that fit the caller's label set does not
470/// fit a narrowed one and the agent refuses the question. Remapping the order onto the kept labels
471/// would silently move options between slots, so it is dropped instead, once per question per call.
472/// Rust has no `warnings` module, so the one-shot notice goes to stderr like the crate's other
473/// diagnostics (`agent.rs`); `warned` tracks which questions have already been reported.
474pub fn drop_stale_option_order(
475    qid: &str,
476    question: &mut Value,
477    n_options: usize,
478    warned: &mut std::collections::HashSet<String>,
479) -> bool {
480    let order = match question
481        .as_object_mut()
482        .and_then(|m| m.remove("option_order"))
483    {
484        Some(o) => o,
485        None => return false,
486    };
487    if warned.insert(qid.to_string()) {
488        let had = match &order {
489            Value::Array(a) => format!("{} slots", a.len()),
490            other => py_json(other),
491        };
492        eprintln!(
493            "laya.shortlist: question {:?}: 'option_order' ({}) was dropped because the label set \
494             was narrowed to {}; the order no longer fits the kept options and remapping it onto \
495             them would move options between slots.",
496            qid, had, n_options
497        );
498    }
499    true
500}
501
502pub const TEMP_MIN: f64 = 0.5;
503pub const TEMP_MAX: f64 = 5.0;
504
505/// A usable temperature: `t` confined to `[TEMP_MIN, TEMP_MAX]`, falling back to 1.0 if not finite.
506pub fn clamp_temperature(t: &Value) -> f64 {
507    let f = match t {
508        Value::Number(n) => n.as_f64(),
509        Value::String(s) => s.trim().parse::<f64>().ok(),
510        _ => None,
511    };
512    match f {
513        Some(x) if x.is_finite() => x.clamp(TEMP_MIN, TEMP_MAX),
514        _ => 1.0,
515    }
516}
517
518/// Temperature bucket key, e.g. `"choice:3-5"`.
519pub fn temp_bucket(qtype: usize, k: usize) -> String {
520    let size = if k <= 2 {
521        "2"
522    } else if k <= 5 {
523        "3-5"
524    } else if k <= 10 {
525        "6-10"
526    } else {
527        "11+"
528    };
529    let name = QType::from_index(qtype)
530        .map(|q| q.name())
531        .unwrap_or("choice");
532    format!("{}:{}", name, size)
533}
534
535/// One tokenized (state, question) pair before collation.
536#[derive(Debug, Clone)]
537pub struct Item {
538    pub ids: Vec<u32>,
539    pub markers: Vec<usize>,
540    pub qtype: usize,
541}
542
543/// A padded batch ready to be turned into tensors.
544#[derive(Debug, Clone)]
545pub struct CollatedBatch {
546    pub input_ids: Vec<Vec<u32>>,
547    pub attention_mask: Vec<Vec<u32>>,
548    pub marker_pos: Vec<Vec<usize>>,
549    pub marker_mask: Vec<Vec<bool>>,
550    pub qtype: Vec<usize>,
551}
552
553/// Collate groups of items into one padded batch. `groups` is a list of per-state item lists,
554/// flattened in order (mirrors `collate_items` in `laya/common.py`). Returns `None` if empty.
555pub fn collate_items(groups: &[Vec<Item>], _pad_id: u32) -> Option<CollatedBatch> {
556    let items: Vec<&Item> = groups.iter().flatten().collect();
557    if items.is_empty() {
558        return None;
559    }
560    let l = items.iter().map(|it| it.ids.len()).max().unwrap();
561    let kmax = items.iter().map(|it| it.markers.len()).max().unwrap();
562
563    let mut input_ids = Vec::with_capacity(items.len());
564    let mut attention_mask = Vec::with_capacity(items.len());
565    let mut marker_pos = Vec::with_capacity(items.len());
566    let mut marker_mask = Vec::with_capacity(items.len());
567    let mut qtype = Vec::with_capacity(items.len());
568
569    for it in &items {
570        let mut ids = it.ids.clone();
571        let mut att = vec![1u32; ids.len()];
572        ids.resize(l, _pad_id);
573        att.resize(l, 0);
574        input_ids.push(ids);
575        attention_mask.push(att);
576
577        let k = it.markers.len();
578        let mut mpos: Vec<usize> = it.markers.clone();
579        mpos.resize(kmax, 0);
580        let mut mmask: Vec<bool> = vec![true; k];
581        mmask.resize(kmax, false);
582        marker_pos.push(mpos);
583        marker_mask.push(mmask);
584
585        qtype.push(it.qtype);
586    }
587
588    Some(CollatedBatch {
589        input_ids,
590        attention_mask,
591        marker_pos,
592        marker_mask,
593        qtype,
594    })
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use serde_json::json;
601
602    /// A deterministic fake tokenizer for structural tests (one id per whitespace word).
603    struct Fake;
604    impl Tokenizer for Fake {
605        fn cls_id(&self) -> u32 {
606            1
607        }
608        fn sep_id(&self) -> u32 {
609            2
610        }
611        fn mask_id(&self) -> u32 {
612            3
613        }
614        fn pad_id(&self) -> u32 {
615            0
616        }
617        fn mask_token(&self) -> &str {
618            "[MASK]"
619        }
620        fn encode(&self, text: &str) -> Vec<u32> {
621            text.split_whitespace()
622                .map(|w| 100 + (w.len() as u32))
623                .collect()
624        }
625    }
626
627    #[test]
628    fn py_json_matches_python_spacing() {
629        // Python json.dumps({"a":1,"b":"x"}, ensure_ascii=False) -> '{"a": 1, "b": "x"}'
630        let v = json!({"a": 1, "b": "x"});
631        assert_eq!(py_json(&v), r#"{"a": 1, "b": "x"}"#);
632        assert_eq!(py_json(&json!([1, 2, 3])), "[1, 2, 3]");
633        assert_eq!(py_json(&json!("café")), "\"café\""); // non-ASCII stays raw
634        assert_eq!(py_json(&json!(true)), "true");
635        assert_eq!(py_json(&json!(null)), "null");
636    }
637
638    #[test]
639    fn serialize_state_passes_strings_through() {
640        assert_eq!(serialize_state(&json!("hello")), "hello");
641        assert_eq!(serialize_state(&json!({"k": "v"})), r#"{"k": "v"}"#);
642    }
643
644    #[test]
645    fn answer_confidence_is_max_prob() {
646        // max(p[:k]), clamped to [0, 1]; k < 1 is defined as fully confident.
647        assert_eq!(answer_confidence(&[0.2, 0.7, 0.1], 3), 0.7);
648        assert_eq!(answer_confidence(&[0.6, 0.4], 2), 0.6);
649        assert_eq!(answer_confidence(&[0.9], 1), 0.9);
650        assert_eq!(answer_confidence(&[], 0), 1.0);
651        // Only the first k entries count.
652        assert_eq!(answer_confidence(&[0.3, 0.3, 0.99], 2), 0.3);
653    }
654
655    #[test]
656    fn round4_half_to_even() {
657        assert_eq!(round4(0.12345), 0.1234); // 0.12345 -> 0.1234 (round half to even)
658        assert_eq!(round4(0.12355), 0.1236);
659        assert_eq!(round4(1.0), 1.0);
660    }
661
662    #[test]
663    fn render_options_per_type() {
664        let choice = InternalQ {
665            t: QType::Choice,
666            ins: String::new(),
667            crit: json!({"a": "first", "b": null, "c": ""}),
668            labels: None,
669            option_order: None,
670        };
671        assert_eq!(render_options(&choice).unwrap(), vec!["a: first", "b", "c"]);
672
673        let score = InternalQ {
674            t: QType::Score,
675            ins: String::new(),
676            crit: json!(["low", "high"]),
677            labels: None,
678            option_order: None,
679        };
680        assert_eq!(
681            render_options(&score).unwrap(),
682            vec!["level 0: low", "level 1: high"]
683        );
684
685        let noul = InternalQ {
686            t: QType::Noul,
687            ins: String::new(),
688            crit: json!({"true": "yes it does", "false": "no"}),
689            labels: None,
690            option_order: None,
691        };
692        assert_eq!(
693            render_options(&noul).unwrap(),
694            vec!["false: no", "true: yes it does"]
695        );
696
697        let noul_default = InternalQ {
698            t: QType::Noul,
699            ins: String::new(),
700            crit: Value::Null,
701            labels: None,
702            option_order: None,
703        };
704        assert_eq!(
705            render_options(&noul_default).unwrap(),
706            vec![
707                "false: no, the statement does not hold",
708                "true: yes, the statement holds"
709            ]
710        );
711    }
712
713    #[test]
714    fn temp_bucket_sizes() {
715        assert_eq!(temp_bucket(0, 2), "choice:2");
716        assert_eq!(temp_bucket(1, 4), "score:3-5");
717        assert_eq!(temp_bucket(2, 8), "noul:6-10");
718        assert_eq!(temp_bucket(0, 20), "choice:11+");
719    }
720
721    #[test]
722    fn build_sequence_structure() {
723        let q = InternalQ {
724            t: QType::Choice,
725            ins: "pick one".to_string(),
726            crit: json!({"a": null, "b": null}),
727            labels: None,
728            option_order: None,
729        };
730        let (ids, markers) =
731            build_sequence(&Fake, &json!("some state text"), &q, 512, 192, None, false).unwrap();
732        assert_eq!(ids[0], 1); // [CLS]
733        assert_eq!(*ids.last().unwrap(), 2); // trailing [SEP]
734        assert_eq!(markers.len(), 2); // one marker per option
735                                      // each marker points at a [MASK] id
736        for &m in &markers {
737            assert_eq!(ids[m], 3);
738        }
739    }
740
741    #[test]
742    fn confidence_bounds() {
743        assert_eq!(confidence_from_probs(&[1.0], 1), 1.0); // k<2
744        let uniform = confidence_from_probs(&[0.5, 0.5], 2);
745        assert!(uniform.abs() < 1e-9); // max entropy -> confidence 0
746        let certain = confidence_from_probs(&[1.0, 0.0], 2);
747        assert!((certain - 1.0).abs() < 1e-9);
748    }
749
750    #[test]
751    fn unpermute_inverts_slot_order() {
752        // Slot 0 shows option 2, slot 1 shows option 0, slot 2 shows option 1. The row is indexed
753        // by slot; unpermuting attaches each slot's mass to the option it showed.
754        let p = [0.7, 0.2, 0.1];
755        let order = [2usize, 0, 1];
756        assert_eq!(unpermute_probs(&p, Some(&order)), vec![0.2, 0.1, 0.7]);
757        // No order, or a length mismatch, leaves the row untouched.
758        assert_eq!(unpermute_probs(&p, None), vec![0.7, 0.2, 0.1]);
759        assert_eq!(unpermute_probs(&p, Some(&[0usize, 1])), vec![0.7, 0.2, 0.1]);
760    }
761
762    #[test]
763    fn build_sequence_honours_option_order() {
764        // With options aa, b, ccc and order [2, 1, 0], the option block runs ccc, b, aa. The Fake
765        // tokenizer maps a word to `100 + len`, so options of distinct lengths land on distinct ids
766        // and the reordered block is observable.
767        let q = InternalQ {
768            t: QType::Choice,
769            ins: "pick one".to_string(),
770            crit: json!({"aa": null, "b": null, "ccc": null}),
771            labels: None,
772            option_order: Some(vec![2, 1, 0]),
773        };
774        let (ids, markers) = build_sequence(
775            &Fake,
776            &json!("state"),
777            &q,
778            512,
779            192,
780            Some(&[2, 1, 0]),
781            false,
782        )
783        .unwrap();
784        assert_eq!(markers.len(), 3);
785        // The token right after each [MASK] marker is the shown option's word: slot 0 shows ccc
786        // (id 103) and slot 2 shows aa (id 102), so the two ends differ.
787        assert_eq!(ids[markers[0] + 1], 103);
788        assert_eq!(ids[markers[2] + 1], 102);
789    }
790
791    fn choice_answer(p: &[f64]) -> Value {
792        let probs: serde_json::Map<String, Value> = p
793            .iter()
794            .enumerate()
795            .map(|(i, &v)| (format!("o{i}"), json!(v)))
796            .collect();
797        json!({ "type": "choice", "probabilities": probs })
798    }
799
800    fn score_answer(p: &[f64]) -> Value {
801        let probs: serde_json::Map<String, Value> = p
802            .iter()
803            .enumerate()
804            .map(|(i, &v)| (i.to_string(), json!(v)))
805            .collect();
806        json!({ "type": "score", "probabilities": probs })
807    }
808
809    #[test]
810    fn jev_confidence_matches_typesafe_vectors() {
811        // Vectors from upstream tests/test_confidence.py (TypeSafe's own + the jevcompat spec).
812        let cases: &[(Value, f64)] = &[
813            (choice_answer(&[0.5, 0.5]), 0.0),
814            (choice_answer(&[0.2, 0.2]), 0.0), // unnormalized but uniform -> 0
815            (choice_answer(&[0.0, 0.0]), 0.0), // zero total read as uniform
816            (choice_answer(&[0.82, 0.18]), 0.64),
817            (choice_answer(&[1.0]), 1.0),
818            (choice_answer(&[0.88, 0.12]), 0.76),
819            (score_answer(&[0.2, 0.2, 0.2, 0.2, 0.2]), 0.0),
820            (score_answer(&[0.04, 0.04, 0.04, 0.04, 0.04]), 0.0),
821            (score_answer(&[0.01, 0.02, 0.07, 0.3, 0.6]), 0.55),
822            (score_answer(&[1.0]), 1.0),
823            (score_answer(&[0.0, 0.95, 0.05]), 0.925),
824        ];
825        for (answer, want) in cases {
826            let got = jev_confidence(answer).expect("a usable confidence");
827            assert!(
828                (got - want).abs() < 1e-9,
829                "answer {answer:?}: got {got}, want {want}"
830            );
831        }
832    }
833
834    #[test]
835    fn jev_confidence_reads_score_levels_in_numeric_order() {
836        // "10" sorts before "2" as text; the mode must still be level 10.
837        let mut probs = serde_json::Map::new();
838        for i in 0..=10 {
839            probs.insert(i.to_string(), json!(0.0));
840        }
841        probs.insert("10".to_string(), json!(1.0));
842        let answer = json!({ "type": "score", "probabilities": probs });
843        assert_eq!(jev_confidence(&answer), Some(1.0));
844    }
845
846    #[test]
847    fn jev_confidence_differs_from_entropy() {
848        // The same distribution #302 measured apart: Jev 0.6, entropy below 0.35.
849        let p = [0.7, 0.1, 0.1, 0.1];
850        let jev = jev_confidence(&choice_answer(&p)).unwrap();
851        assert!((jev - 0.6).abs() < 1e-9, "jev {jev}");
852        assert!(confidence_from_probs(&p, 4) < 0.35);
853    }
854
855    #[test]
856    fn jev_confidence_none_for_unusable_answers() {
857        let none_cases = [
858            json!({ "type": "noul", "noul": 0.9 }),
859            json!({ "type": "weird", "probabilities": { "a": 1.0 } }),
860            json!({ "type": "choice" }),
861            json!({ "type": "choice", "probabilities": {} }),
862            json!({ "type": "choice", "probabilities": { "o0": 0.5, "o1": "high" } }),
863            json!({ "type": "choice", "probabilities": { "o0": true, "o1": false } }),
864            json!({ "type": "score", "probabilities": { "low": 0.5, "high": 0.5 } }),
865        ];
866        for answer in none_cases {
867            assert_eq!(jev_confidence(&answer), None, "answer {answer:?}");
868        }
869    }
870
871    #[test]
872    fn drop_stale_option_order_drops_and_dedupes() {
873        use std::collections::HashSet;
874        let mut warned = HashSet::new();
875        let mut q =
876            json!({ "type": "choice", "criteria": ["a", "b"], "option_order": [3, 2, 1, 0] });
877        assert!(drop_stale_option_order("intent", &mut q, 2, &mut warned));
878        assert!(q.get("option_order").is_none(), "stale order removed");
879        assert_eq!(
880            q.get("criteria"),
881            Some(&json!(["a", "b"])),
882            "criteria untouched"
883        );
884        assert!(warned.contains("intent"));
885        // A question with no order is left alone and reports nothing.
886        let mut plain = json!({ "type": "choice", "criteria": ["a", "b"] });
887        assert!(!drop_stale_option_order(
888            "intent",
889            &mut plain,
890            2,
891            &mut warned
892        ));
893        assert_eq!(warned.len(), 1, "no new warning for an order-free question");
894    }
895}