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
397pub const TEMP_MIN: f64 = 0.5;
398pub const TEMP_MAX: f64 = 5.0;
399
400/// A usable temperature: `t` confined to `[TEMP_MIN, TEMP_MAX]`, falling back to 1.0 if not finite.
401pub fn clamp_temperature(t: &Value) -> f64 {
402    let f = match t {
403        Value::Number(n) => n.as_f64(),
404        Value::String(s) => s.trim().parse::<f64>().ok(),
405        _ => None,
406    };
407    match f {
408        Some(x) if x.is_finite() => x.clamp(TEMP_MIN, TEMP_MAX),
409        _ => 1.0,
410    }
411}
412
413/// Temperature bucket key, e.g. `"choice:3-5"`.
414pub fn temp_bucket(qtype: usize, k: usize) -> String {
415    let size = if k <= 2 {
416        "2"
417    } else if k <= 5 {
418        "3-5"
419    } else if k <= 10 {
420        "6-10"
421    } else {
422        "11+"
423    };
424    let name = QType::from_index(qtype)
425        .map(|q| q.name())
426        .unwrap_or("choice");
427    format!("{}:{}", name, size)
428}
429
430/// One tokenized (state, question) pair before collation.
431#[derive(Debug, Clone)]
432pub struct Item {
433    pub ids: Vec<u32>,
434    pub markers: Vec<usize>,
435    pub qtype: usize,
436}
437
438/// A padded batch ready to be turned into tensors.
439#[derive(Debug, Clone)]
440pub struct CollatedBatch {
441    pub input_ids: Vec<Vec<u32>>,
442    pub attention_mask: Vec<Vec<u32>>,
443    pub marker_pos: Vec<Vec<usize>>,
444    pub marker_mask: Vec<Vec<bool>>,
445    pub qtype: Vec<usize>,
446}
447
448/// Collate groups of items into one padded batch. `groups` is a list of per-state item lists,
449/// flattened in order (mirrors `collate_items` in `laya/common.py`). Returns `None` if empty.
450pub fn collate_items(groups: &[Vec<Item>], _pad_id: u32) -> Option<CollatedBatch> {
451    let items: Vec<&Item> = groups.iter().flatten().collect();
452    if items.is_empty() {
453        return None;
454    }
455    let l = items.iter().map(|it| it.ids.len()).max().unwrap();
456    let kmax = items.iter().map(|it| it.markers.len()).max().unwrap();
457
458    let mut input_ids = Vec::with_capacity(items.len());
459    let mut attention_mask = Vec::with_capacity(items.len());
460    let mut marker_pos = Vec::with_capacity(items.len());
461    let mut marker_mask = Vec::with_capacity(items.len());
462    let mut qtype = Vec::with_capacity(items.len());
463
464    for it in &items {
465        let mut ids = it.ids.clone();
466        let mut att = vec![1u32; ids.len()];
467        ids.resize(l, _pad_id);
468        att.resize(l, 0);
469        input_ids.push(ids);
470        attention_mask.push(att);
471
472        let k = it.markers.len();
473        let mut mpos: Vec<usize> = it.markers.clone();
474        mpos.resize(kmax, 0);
475        let mut mmask: Vec<bool> = vec![true; k];
476        mmask.resize(kmax, false);
477        marker_pos.push(mpos);
478        marker_mask.push(mmask);
479
480        qtype.push(it.qtype);
481    }
482
483    Some(CollatedBatch {
484        input_ids,
485        attention_mask,
486        marker_pos,
487        marker_mask,
488        qtype,
489    })
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use serde_json::json;
496
497    /// A deterministic fake tokenizer for structural tests (one id per whitespace word).
498    struct Fake;
499    impl Tokenizer for Fake {
500        fn cls_id(&self) -> u32 {
501            1
502        }
503        fn sep_id(&self) -> u32 {
504            2
505        }
506        fn mask_id(&self) -> u32 {
507            3
508        }
509        fn pad_id(&self) -> u32 {
510            0
511        }
512        fn mask_token(&self) -> &str {
513            "[MASK]"
514        }
515        fn encode(&self, text: &str) -> Vec<u32> {
516            text.split_whitespace()
517                .map(|w| 100 + (w.len() as u32))
518                .collect()
519        }
520    }
521
522    #[test]
523    fn py_json_matches_python_spacing() {
524        // Python json.dumps({"a":1,"b":"x"}, ensure_ascii=False) -> '{"a": 1, "b": "x"}'
525        let v = json!({"a": 1, "b": "x"});
526        assert_eq!(py_json(&v), r#"{"a": 1, "b": "x"}"#);
527        assert_eq!(py_json(&json!([1, 2, 3])), "[1, 2, 3]");
528        assert_eq!(py_json(&json!("café")), "\"café\""); // non-ASCII stays raw
529        assert_eq!(py_json(&json!(true)), "true");
530        assert_eq!(py_json(&json!(null)), "null");
531    }
532
533    #[test]
534    fn serialize_state_passes_strings_through() {
535        assert_eq!(serialize_state(&json!("hello")), "hello");
536        assert_eq!(serialize_state(&json!({"k": "v"})), r#"{"k": "v"}"#);
537    }
538
539    #[test]
540    fn answer_confidence_is_max_prob() {
541        // max(p[:k]), clamped to [0, 1]; k < 1 is defined as fully confident.
542        assert_eq!(answer_confidence(&[0.2, 0.7, 0.1], 3), 0.7);
543        assert_eq!(answer_confidence(&[0.6, 0.4], 2), 0.6);
544        assert_eq!(answer_confidence(&[0.9], 1), 0.9);
545        assert_eq!(answer_confidence(&[], 0), 1.0);
546        // Only the first k entries count.
547        assert_eq!(answer_confidence(&[0.3, 0.3, 0.99], 2), 0.3);
548    }
549
550    #[test]
551    fn round4_half_to_even() {
552        assert_eq!(round4(0.12345), 0.1234); // 0.12345 -> 0.1234 (round half to even)
553        assert_eq!(round4(0.12355), 0.1236);
554        assert_eq!(round4(1.0), 1.0);
555    }
556
557    #[test]
558    fn render_options_per_type() {
559        let choice = InternalQ {
560            t: QType::Choice,
561            ins: String::new(),
562            crit: json!({"a": "first", "b": null, "c": ""}),
563            labels: None,
564            option_order: None,
565        };
566        assert_eq!(render_options(&choice).unwrap(), vec!["a: first", "b", "c"]);
567
568        let score = InternalQ {
569            t: QType::Score,
570            ins: String::new(),
571            crit: json!(["low", "high"]),
572            labels: None,
573            option_order: None,
574        };
575        assert_eq!(
576            render_options(&score).unwrap(),
577            vec!["level 0: low", "level 1: high"]
578        );
579
580        let noul = InternalQ {
581            t: QType::Noul,
582            ins: String::new(),
583            crit: json!({"true": "yes it does", "false": "no"}),
584            labels: None,
585            option_order: None,
586        };
587        assert_eq!(
588            render_options(&noul).unwrap(),
589            vec!["false: no", "true: yes it does"]
590        );
591
592        let noul_default = InternalQ {
593            t: QType::Noul,
594            ins: String::new(),
595            crit: Value::Null,
596            labels: None,
597            option_order: None,
598        };
599        assert_eq!(
600            render_options(&noul_default).unwrap(),
601            vec![
602                "false: no, the statement does not hold",
603                "true: yes, the statement holds"
604            ]
605        );
606    }
607
608    #[test]
609    fn temp_bucket_sizes() {
610        assert_eq!(temp_bucket(0, 2), "choice:2");
611        assert_eq!(temp_bucket(1, 4), "score:3-5");
612        assert_eq!(temp_bucket(2, 8), "noul:6-10");
613        assert_eq!(temp_bucket(0, 20), "choice:11+");
614    }
615
616    #[test]
617    fn build_sequence_structure() {
618        let q = InternalQ {
619            t: QType::Choice,
620            ins: "pick one".to_string(),
621            crit: json!({"a": null, "b": null}),
622            labels: None,
623            option_order: None,
624        };
625        let (ids, markers) =
626            build_sequence(&Fake, &json!("some state text"), &q, 512, 192, None, false).unwrap();
627        assert_eq!(ids[0], 1); // [CLS]
628        assert_eq!(*ids.last().unwrap(), 2); // trailing [SEP]
629        assert_eq!(markers.len(), 2); // one marker per option
630                                      // each marker points at a [MASK] id
631        for &m in &markers {
632            assert_eq!(ids[m], 3);
633        }
634    }
635
636    #[test]
637    fn confidence_bounds() {
638        assert_eq!(confidence_from_probs(&[1.0], 1), 1.0); // k<2
639        let uniform = confidence_from_probs(&[0.5, 0.5], 2);
640        assert!(uniform.abs() < 1e-9); // max entropy -> confidence 0
641        let certain = confidence_from_probs(&[1.0, 0.0], 2);
642        assert!((certain - 1.0).abs() < 1e-9);
643    }
644
645    #[test]
646    fn unpermute_inverts_slot_order() {
647        // Slot 0 shows option 2, slot 1 shows option 0, slot 2 shows option 1. The row is indexed
648        // by slot; unpermuting attaches each slot's mass to the option it showed.
649        let p = [0.7, 0.2, 0.1];
650        let order = [2usize, 0, 1];
651        assert_eq!(unpermute_probs(&p, Some(&order)), vec![0.2, 0.1, 0.7]);
652        // No order, or a length mismatch, leaves the row untouched.
653        assert_eq!(unpermute_probs(&p, None), vec![0.7, 0.2, 0.1]);
654        assert_eq!(unpermute_probs(&p, Some(&[0usize, 1])), vec![0.7, 0.2, 0.1]);
655    }
656
657    #[test]
658    fn build_sequence_honours_option_order() {
659        // With options aa, b, ccc and order [2, 1, 0], the option block runs ccc, b, aa. The Fake
660        // tokenizer maps a word to `100 + len`, so options of distinct lengths land on distinct ids
661        // and the reordered block is observable.
662        let q = InternalQ {
663            t: QType::Choice,
664            ins: "pick one".to_string(),
665            crit: json!({"aa": null, "b": null, "ccc": null}),
666            labels: None,
667            option_order: Some(vec![2, 1, 0]),
668        };
669        let (ids, markers) = build_sequence(
670            &Fake,
671            &json!("state"),
672            &q,
673            512,
674            192,
675            Some(&[2, 1, 0]),
676            false,
677        )
678        .unwrap();
679        assert_eq!(markers.len(), 3);
680        // The token right after each [MASK] marker is the shown option's word: slot 0 shows ccc
681        // (id 103) and slot 2 shows aa (id 102), so the two ends differ.
682        assert_eq!(ids[markers[0] + 1], 103);
683        assert_eq!(ids[markers[2] + 1], 102);
684    }
685}