Skip to main content

embroider/
decider2b.rs

1//! Slot-layout typed decisions: serving Mapika/decider-2b.
2//!
3//! GLiClass splices options into the token stream (2 inputs, label
4//! text). Laya gathers marker slots (5 inputs). decider-2b is a third
5//! contract: a Qwen3.5-2B decoder where each question is one token row —
6//! `Context:` + state, then `\n\nQuestion: …\nOptions:` with lettered
7//! options and a trailing `Answer: (` whose `(` slot is scored. 2 inputs
8//! (`input_ids`, `slot_pos`), 1 output (`label_logits` [rows, 255]); no
9//! attention mask (causal; right padding never reaches earlier
10//! positions). This module ports
11//! `ollaya_convert.families.decider.layout` 1:1 — render, lettered/wide
12//! pieces, isolated yes/no score rows, row batching, option logits —
13//! plus the checkpoint temperatures from `calibration.json`, so
14//! embroider serves the `opticsWolf/decider-2b-fp32-onnx` weightless
15//! export bit-faithfully instead of approximating it.
16//!
17//! Question shapes mirror `LayaQuestion` on purpose (Choice with
18//! descriptions, level Scores, Noul) but the type is its own: decider's
19//! noul carries **both** criteria (`true` and `false` descriptions —
20//! every S1MB noul ships both), where Laya's has one statement. Sharing
21//! the enum would silently drop the false side on the whole noul bench.
22//! Same reason `Decider::open` refuses this id: a slot graph fed to the
23//! 2-input GLiClass path would fail late instead of routing here.
24//!
25//! CUDA runs the **parallel executor** (`SessionPolicy::decider2b`): the
26//! sequential memory planner aliases a shape buffer on this graph (ORT
27//! #4829 class — a chunk-scan Reshape reads a zeroed dim and the run
28//! fails deterministically on the first batch), and the C-level
29//! `enable_mem_reuse=false` that fixes it is not reachable from the C
30//! API (no setter; the `session.enable_mem_reuse` config key is not
31//! honored — both probed). Parallel execution is parity-identical on
32//! CPU + CUDA (e2e-pinned) — but only with ORT's default thread pools:
33//! an explicit `inter_op_num_threads=1` re-triggers the corruption
34//! under the parallel executor (probed), so the policy sets no thread
35//! counts. The memory-pattern switch is NOT an
36//! equivalent (probed: still corrupt).
37//!
38//! Deliberately NOT ported: anything the checkpoint doesn't ship —
39//! fitted binning maps (consumer-side, as with Laya), `predict_long`
40//! window scans (the caller windows + aggregates; per-question decode
41//! here is already order-preserving so scans compose).
42
43use std::collections::HashSet;
44use std::path::{Path, PathBuf};
45
46use anyhow::{anyhow, Result};
47use ndarray::Array2;
48
49use crate::acquire::{
50    artifact_for_task, fetch_repo_file, lookup_task, TaskKind, DECIDER2B_MODEL,
51};
52use crate::error::oe;
53use crate::jina::{count_tokens_in, load_tokenizer};
54use crate::laya::{answer_confidence, confidence_from_probs, softmax_stable, temperature_rejected};
55use crate::policy::{resolve_provider_names, DeviceReq, Precision, SessionPolicy};
56use crate::providers::apply_providers_with_arena;
57
58/// One decider-2b option: a label with an optional description (rendered
59/// as `label: description`, the description side of decider's criteria
60/// dict). Labels are the answer keys and must be distinct.
61#[derive(Clone, Debug, PartialEq)]
62pub struct Decider2BOption {
63    pub label: String,
64    pub description: Option<String>,
65}
66
67impl Decider2BOption {
68    pub fn plain(label: &str) -> Self {
69        Self { label: label.to_string(), description: None }
70    }
71}
72
73/// A decider-2b question. Shapes mirror `LayaQuestion` deliberately —
74/// Choice: one distribution over options; Score: one distribution over
75/// LEVELS (expected index in `.value`); Noul: P(true) for the pair of
76/// criteria (the rerank primitive) — but noul carries **both** sides:
77/// `true_criterion` / `false_criterion` render as `yes: …` / `no: …`
78/// (bare `yes` / `no` when empty), exactly like decider's own criteria
79/// dict. Options render in label order (no `option_order`: the reference
80/// layout has no such concept - a caller that wants position de-biasing
81/// permutes the options vec itself before calling).
82#[derive(Clone, Debug, PartialEq)]
83pub enum Decider2BQuestion {
84    Choice {
85        instructions: String,
86        options: Vec<Decider2BOption>,
87    },
88    Score {
89        instructions: String,
90        levels: Vec<String>,
91    },
92    Noul {
93        instructions: String,
94        true_criterion: String,
95        false_criterion: String,
96    },
97}
98
99/// One answered decider-2b question. Native f32 throughout (presentation
100/// rounding is the caller's; goldens pin full precision). No escalation
101/// head exists on this graph, so there is no `act_probability` — the
102/// tuple is five wide, not six.
103#[derive(Clone, Debug, PartialEq)]
104pub struct Decider2BDecision {
105    /// Choice: winning label. Score: top level's text. Noul:
106    /// "true"/"false".
107    pub label: String,
108    /// Choice: per-option (label, p). Score: per-level (text, p). Noul:
109    /// `[("false", p0), ("true", p1)]`. Caller order (unpermuted).
110    pub probabilities: Vec<(String, f32)>,
111    /// Score only: the expected level index (`Σ i·pᵢ`).
112    pub value: Option<f32>,
113    /// Noul: max(p) (the two-sided certainty). Elsewhere the
114    /// Shannon-concentration confidence (1.0 for k < 2).
115    pub confidence: f32,
116    /// Calibrated confidence: max(p) — what the temperatures were fitted on.
117    pub answer_confidence: f32,
118}
119
120/// Usable-temperature clamp for this checkpoint: [0.05, 5.0]
121/// (`temperature_range` in calibration.json) — NOT Laya's [0.5, 5.0]:
122/// the fitted noul temperature IS 0.05 and must survive the clamp.
123/// NaN/inf/non-number entries read 1.0 (unfitted bucket).
124pub(crate) fn clamp_decider2b_temperature(t: f64) -> f64 {
125    if t.is_nan() || t.is_infinite() {
126        return 1.0;
127    }
128    t.clamp(0.05, 5.0)
129}
130
131/// Checkpoint temperatures as loaded: the (choice, score, noul) triple
132/// after the [0.05, 5.0] clamp, plus the load-time rejection list (the
133/// agent's load-time warnings, queryable instead of printed).
134#[derive(Clone, Debug)]
135pub struct Decider2BTemperatures {
136    pub triple: [f64; 3],
137    pub rejections: Vec<String>,
138}
139
140impl Decider2BTemperatures {
141    fn load(cfg: &serde_json::Value) -> Result<Self> {
142        let raw_triple = cfg.get("temperature").and_then(|v| v.as_array()).ok_or_else(|| {
143            anyhow!("calibration.json has no 'temperature' triple - not a decider-2b checkpoint")
144        })?;
145        if raw_triple.len() != 3 {
146            return Err(anyhow!(
147                "calibration.json 'temperature' must have 3 entries, got {}",
148                raw_triple.len()
149            ));
150        }
151        let mut triple = [1.0f64; 3];
152        let mut rejections = Vec::new();
153        for (i, raw) in raw_triple.iter().enumerate() {
154            let applied = clamp_decider2b_temperature(raw.as_f64().unwrap_or(f64::NAN));
155            triple[i] = applied;
156            if temperature_rejected(raw, applied) {
157                rejections.push(format!("temperature[{i}]={raw} -> {applied}"));
158            }
159        }
160        Ok(Self { triple, rejections })
161    }
162}
163
164fn qtype_of(q: &Decider2BQuestion) -> usize {
165    match q {
166        Decider2BQuestion::Choice { .. } => 0,
167        Decider2BQuestion::Score { .. } => 1,
168        Decider2BQuestion::Noul { .. } => 2,
169    }
170}
171
172/// Width at/below which options render as lettered text (`(A) …`);
173/// wider questions address one label token per option instead
174/// (`decider-slots-v1`: `NARROW = 10`).
175const NARROW: usize = 10;
176const LETTERS: &[u8] = b"ABCDEFGHIJ";
177/// Isolated yes/no score rows (`ISOLATED`): the level proposes, the
178/// pair disposes. Thepiece adds the `\n\nQuestion:` prefix — this is
179/// only the `{q}` body (`_piece` is shared with the listwise path).
180const ISOLATED_Q: &str = "{q}\nProposed answer: {level}\nDoes the proposed answer fit?";
181/// Row-batch token budget (`ort_rows.run_rows`: `budget = 8192`) and the
182/// graph's sequence multiple (positions are implicit `0..seq-1`).
183const ROW_BUDGET: usize = 8192;
184const SEQ_MULTIPLE: usize = 64;
185
186/// Strip a leading `N: ` level number (`strip_level_number`): the
187/// isolated template takes the raw level text, and numbered legends
188/// (`0: low`) would double-number it.
189pub(crate) fn strip_level_number(text: &str) -> String {
190    let t = text.trim_start();
191    let mut rest = t;
192    if rest.starts_with('-') {
193        rest = &rest[1..];
194    }
195    let digits = rest.bytes().take_while(|b| b.is_ascii_digit()).count();
196    if digits > 0 {
197        let after = &rest[digits..];
198        if let Some(stripped) = after.strip_prefix(':') {
199            return stripped.trim_start().to_string();
200        }
201    }
202    text.to_string()
203}
204
205/// Render option texts in label-index order (`render_question`):
206/// - Choice: `label` or `label: description`.
207/// - Score listwise (`isolated_levels=false` only): `i: description`.
208/// - Noul: `no` / `no: {false}` then `yes` / `yes: {true}`.
209fn render_decider_options(q: &Decider2BQuestion) -> Vec<String> {
210    match q {
211        Decider2BQuestion::Choice { options, .. } => options
212            .iter()
213            .map(|o| match &o.description {
214                Some(d) if !d.is_empty() => format!("{}: {d}", o.label),
215                _ => o.label.clone(),
216            })
217            .collect(),
218        Decider2BQuestion::Score { levels, .. } => levels
219            .iter()
220            .enumerate()
221            .map(|(i, c)| format!("{i}: {c}"))
222            .collect(),
223        Decider2BQuestion::Noul { true_criterion, false_criterion, .. } => vec![
224            if false_criterion.is_empty() {
225                "no".to_string()
226            } else {
227                format!("no: {false_criterion}")
228            },
229            if true_criterion.is_empty() {
230                "yes".to_string()
231            } else {
232                format!("yes: {true_criterion}")
233            },
234        ],
235    }
236}
237
238/// Reject a question that cannot be answered, naming it. Validation
239/// fires before any tokenizer or session work; caps come from the
240/// checkpoint config (`max_options`, `max_levels`), not the code.
241fn validate_decider2b_question(
242    i: usize,
243    q: &Decider2BQuestion,
244    max_options: usize,
245    max_levels: usize,
246) -> Result<()> {
247    let instructions = match q {
248        Decider2BQuestion::Choice { instructions, .. }
249        | Decider2BQuestion::Score { instructions, .. }
250        | Decider2BQuestion::Noul { instructions, .. } => instructions,
251    };
252    if instructions.trim().is_empty() {
253        return Err(anyhow!(
254            "question {i}: 'instructions' must not be empty; add the text the model should answer"
255        ));
256    }
257    match q {
258        Decider2BQuestion::Choice { options, .. } => {
259            if options.len() < 2 {
260                return Err(anyhow!(
261                    "question {i}: a choice question needs at least 2 options, got {}",
262                    options.len()
263                ));
264            }
265            if options.len() > max_options {
266                return Err(anyhow!(
267                    "question {i}: {} options exceed the {max_options} label slots this checkpoint serves",
268                    options.len()
269                ));
270            }
271            let mut seen = HashSet::new();
272            for o in options {
273                if !seen.insert(o.label.clone()) {
274                    return Err(anyhow!(
275                        "question {i}: duplicate choice label {:?}; labels are the answer keys and must be distinct",
276                        o.label
277                    ));
278                }
279            }
280        }
281        Decider2BQuestion::Score { levels, .. } => {
282            if levels.len() < 2 {
283                return Err(anyhow!(
284                    "question {i}: a score question needs at least 2 levels, got {}",
285                    levels.len()
286                ));
287            }
288            if levels.len() > max_levels {
289                return Err(anyhow!(
290                    "question {i}: {} levels exceed the {max_levels} isolated rows this checkpoint serves",
291                    levels.len()
292                ));
293            }
294        }
295        Decider2BQuestion::Noul { .. } => {}
296    }
297    Ok(())
298}
299
300/// One lettered/wide question piece: `\n\nQuestion: …\nOptions:` +
301/// options + `\nAnswer: (` (`_piece`). Narrow questions letter inline;
302/// wide ones address one label token per option. Returns ids + the slot
303/// (the `(` token: its next-token logits over label ids are the option
304/// logits) + the option count.
305fn build_piece(
306    tok: &tokenizers::Tokenizer,
307    question: &str,
308    options: &[String],
309    label_ids: &[u32],
310) -> Result<(Vec<u32>, usize)> {
311    let head_text = format!("\n\nQuestion: {question}\nOptions:");
312    if options.len() <= NARROW {
313        let mut text = head_text;
314        for (j, o) in options.iter().enumerate() {
315            text.push_str(&format!("\n({}) {o}", LETTERS[j] as char));
316        }
317        text.push_str("\nAnswer: (");
318        let ids = tok
319            .encode(text.as_str(), false)
320            .map_err(|e| anyhow!("question encode: {e}"))?;
321        let mut v: Vec<u32> = ids.get_ids().to_vec();
322        let slot = v.len() - 1;
323        Ok((v, slot))
324    } else {
325        let mut v: Vec<u32> = tok
326            .encode(head_text.as_str(), false)
327            .map_err(|e| anyhow!("question encode: {e}"))?
328            .get_ids()
329            .to_vec();
330        let open: Vec<u32> = tok
331            .encode("\n(", false)
332            .map_err(|e| anyhow!("open-paren encode: {e}"))?
333            .get_ids()
334            .to_vec();
335        for (j, o) in options.iter().enumerate() {
336            let label_id = *label_ids.get(j).ok_or_else(|| {
337                anyhow!("wide question needs label id {j} ({} options)", options.len())
338            })?;
339            v.extend_from_slice(&open);
340            v.push(label_id);
341            let rest: Vec<u32> = tok
342                .encode(format!(") {o}").as_str(), false)
343                .map_err(|e| anyhow!("option encode: {e}"))?
344                .get_ids()
345                .to_vec();
346            v.extend_from_slice(&rest);
347        }
348        let tail: Vec<u32> = tok
349            .encode("\nAnswer: (", false)
350            .map_err(|e| anyhow!("answer-tail encode: {e}"))?
351            .get_ids()
352            .to_vec();
353        v.extend_from_slice(&tail);
354        let slot = v.len() - 1;
355        Ok((v, slot))
356    }
357}
358
359/// One encoded row: token ids + the answer slot + the option count.
360/// Score questions with `isolated_levels` become one yes/no row per
361/// level; everything else is one row.
362struct EncRow {
363    ids: Vec<u32>,
364    slot: usize,
365    k: usize,
366}
367
368/// Isolated yes/no row for one level (`encode` score branch).
369fn build_isolated_row(
370    tok: &tokenizers::Tokenizer,
371    ctx: &[u32],
372    instructions: &str,
373    level: &str,
374    label_ids: &[u32],
375    max_ctx: usize,
376) -> Result<EncRow> {
377    let q = ISOLATED_Q
378        .replace("{q}", instructions)
379        .replace("{level}", &strip_level_number(level));
380    let (mut piece, slot_in_piece) = build_piece(tok, &q, &["no".to_string(), "yes".to_string()], label_ids)?;
381    let mut ids = Vec::with_capacity(ctx.len().min(max_ctx) + piece.len());
382    ids.extend_from_slice(&ctx[..ctx.len().min(max_ctx)]);
383    let slot = ids.len() + slot_in_piece;
384    ids.append(&mut piece);
385    Ok(EncRow { ids, slot, k: 2 })
386}
387
388/// Encode one (state, question) pair into rows (`encode`): shared
389/// `Context:` prefix truncated to the checkpoint ceiling, then the
390/// piece(s). Plan rows index the shared row list.
391fn encode_pair(
392    tok: &tokenizers::Tokenizer,
393    ctx: &[u32],
394    q: &Decider2BQuestion,
395    label_ids: &[u32],
396    max_ctx: usize,
397    isolated_levels: bool,
398) -> Result<(Vec<EncRow>, PlanItem)> {
399    let instructions = match q {
400        Decider2BQuestion::Choice { instructions, .. }
401        | Decider2BQuestion::Score { instructions, .. }
402        | Decider2BQuestion::Noul { instructions, .. } => instructions,
403    };
404    match q {
405        Decider2BQuestion::Score { levels, .. } if isolated_levels => {
406            let mut rows = Vec::with_capacity(levels.len());
407            let mut idx = Vec::with_capacity(levels.len());
408            // rows accumulate in the caller; indices are relative here
409            // and rebased by the caller.
410            for level in levels {
411                rows.push(build_isolated_row(tok, ctx, instructions, level, label_ids, max_ctx)?);
412                idx.push(rows.len() - 1);
413            }
414            Ok((
415                rows,
416                PlanItem { qtype: 1, kind: RowKind::Iso, rows: idx, k: levels.len() },
417            ))
418        }
419        _ => {
420            let rendered = render_decider_options(q);
421            let (mut piece, slot_in_piece) = build_piece(tok, instructions, &rendered, label_ids)?;
422            let mut ids = Vec::with_capacity(ctx.len().min(max_ctx) + piece.len());
423            ids.extend_from_slice(&ctx[..ctx.len().min(max_ctx)]);
424            let slot = ids.len() + slot_in_piece;
425            ids.append(&mut piece);
426            let k = rendered.len();
427            Ok((
428                vec![EncRow { ids, slot, k }],
429                PlanItem { qtype: qtype_of(q), kind: RowKind::List, rows: vec![0], k },
430            ))
431        }
432    }
433}
434
435#[derive(Clone, Copy, PartialEq)]
436enum RowKind {
437    List,
438    Iso,
439}
440
441struct PlanItem {
442    qtype: usize,
443    kind: RowKind,
444    rows: Vec<usize>,
445    k: usize,
446}
447
448/// Per-question option logits from the graph output `label_logits`
449/// [rows, 255] (`option_logits`): listwise rows read the first-k label
450/// logits; isolated rows combine each yes/no pair into
451/// `log_sigmoid((yes − no) / T_row)` so `softmax(z)` is the upstream
452/// normalised per-level fit (the score calibration temperature is 1.0
453/// because `T_row` is already inside `z`).
454fn option_logits(item: &PlanItem, outs: &[Vec<f32>], row_temp: f64) -> Vec<f64> {
455    match item.kind {
456        RowKind::List => {
457            let r = item.rows[0];
458            outs[r].iter().take(item.k).map(|&x| x as f64).collect()
459        }
460        RowKind::Iso => item
461            .rows
462            .iter()
463            .map(|&r| {
464                let d = (outs[r][1] as f64 - outs[r][0] as f64) / row_temp;
465                if d > -30.0 {
466                    -(1.0 + (-d).exp()).ln()
467                } else {
468                    d - (1.0 + d.exp()).ln()
469                }
470            })
471            .collect(),
472    }
473}
474
475/// Pack row indices into ort_rows batches: length-sorted, each batch
476/// holding while `L2 * count <= budget` with `L2` the 64-ceiling.
477/// `L` is the batch's padded width.
478pub(crate) fn pack_row_batches(lens: &[usize]) -> Vec<(Vec<usize>, usize)> {
479    let mut order: Vec<usize> = (0..lens.len()).collect();
480    order.sort_by_key(|&i| lens[i]);
481    let mut out = Vec::new();
482    let mut i = 0;
483    while i < order.len() {
484        let l0 = lens[order[i]].div_ceil(SEQ_MULTIPLE) * SEQ_MULTIPLE;
485        let mut l = l0;
486        let mut j = i + 1;
487        while j < order.len() {
488            let l2 = lens[order[j]].div_ceil(SEQ_MULTIPLE) * SEQ_MULTIPLE;
489            if l2 * (j + 1 - i) > ROW_BUDGET {
490                break;
491            }
492            l = l2;
493            j += 1;
494        }
495        out.push((order[i..j].to_vec(), l));
496        i = j;
497    }
498    out
499}
500
501/// Decode one question from its row outputs (`_decode_answers` core:
502/// temperature → softmax → slot→caller unpermute → per-type shape).
503/// `order` is the choice slot permutation (None = identity).
504#[allow(clippy::too_many_arguments)]
505pub(crate) fn decode_decider2b_row(
506    logits: &[f64],
507    q: &Decider2BQuestion,
508    triple: &[f64; 3],
509) -> Decider2BDecision {
510    let qt = qtype_of(q);
511    let k = logits.len();
512    let t = triple[qt];
513    let z: Vec<f64> = logits.iter().map(|&x| x / t).collect();
514    let p = softmax_stable(&z);
515    let pf: Vec<f32> = p.iter().map(|&x| x as f32).collect();
516    // Noul reports max(P(true), P(false)) — the two-sided certainty —
517    // not the entropy concentration.
518    let confidence = if qt == 2 {
519        p[1].max(1.0 - p[1]) as f32
520    } else {
521        confidence_from_probs(&p) as f32
522    };
523    let answer_confidence = answer_confidence(&p) as f32;
524    match q {
525        Decider2BQuestion::Choice { options, .. } => {
526            let win = pf
527                .iter()
528                .enumerate()
529                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
530                .map(|(i, _)| i)
531                .unwrap_or(0);
532            Decider2BDecision {
533                label: options.get(win).map(|o| o.label.clone()).unwrap_or_default(),
534                probabilities: options
535                    .iter()
536                    .zip(pf.iter())
537                    .map(|(o, &v)| (o.label.clone(), v))
538                    .collect(),
539                value: None,
540                confidence,
541                answer_confidence,
542            }
543        }
544        Decider2BQuestion::Score { levels, .. } => {
545            let value: f64 = p.iter().enumerate().map(|(i, &x)| i as f64 * x).sum();
546            let win = pf
547                .iter()
548                .enumerate()
549                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
550                .map(|(i, _)| i)
551                .unwrap_or(0);
552            Decider2BDecision {
553                label: levels.get(win).cloned().unwrap_or_default(),
554                probabilities: levels
555                    .iter()
556                    .zip(pf.iter())
557                    .map(|(l, &v)| (l.clone(), v))
558                    .collect(),
559                value: Some(value as f32),
560                confidence,
561                answer_confidence,
562            }
563        }
564        Decider2BQuestion::Noul { .. } => {
565            let label = if p[1] >= p[0] { "true" } else { "false" }.to_string();
566            Decider2BDecision {
567                label,
568                probabilities: vec![("false".to_string(), pf[0]), ("true".to_string(), pf[1])],
569                value: None,
570                confidence,
571                answer_confidence,
572            }
573        }
574    }
575}
576
577fn check_decider2b_limit_for(max_length: Option<usize>, ceiling: usize) -> Result<usize> {
578    match max_length {
579        None => Ok(ceiling),
580        Some(n) if (1..=ceiling).contains(&n) => Ok(n),
581        Some(n) => Err(anyhow!(
582            "max_length must be within 1..={ceiling} for this checkpoint, got {n}"
583        )),
584    }
585}
586
587/// The checkpoint contract: option/label ceilings, the isolated-row
588/// temperature and label alphabet from `decision.json`, plus the
589/// clamped per-qtype temperatures from `calibration.json`.
590#[derive(Clone, Debug)]
591pub struct Decider2BConfig {
592    pub max_ctx: usize,
593    pub max_options: usize,
594    pub min_options: usize,
595    pub max_levels: usize,
596    pub isolated_levels: bool,
597    pub row_temp: f64,
598    pub label_ids: Vec<u32>,
599    pub pad_id: u32,
600    pub temps: Decider2BTemperatures,
601}
602
603impl Decider2BConfig {
604    fn load(decision: &serde_json::Value, calibration: &serde_json::Value) -> Result<Self> {
605        let layout = decision.get("layout").and_then(|v| v.as_str()).unwrap_or("");
606        if layout != "decider-slots-v1" {
607            return Err(anyhow!(
608                "decision.json layout={layout:?} is not the decider-slots-v1 contract this runtime serves"
609            ));
610        }
611        let get_usize = |key: &str| {
612            decision
613                .get(key)
614                .and_then(|v| v.as_u64())
615                .filter(|&v| v > 0)
616                .ok_or_else(|| anyhow!("decision.json has no positive '{key}'"))
617                .map(|v| v as usize)
618        };
619        let max_ctx = get_usize("max_ctx_tokens")?;
620        let max_options = get_usize("max_options")?;
621        let min_options = decision
622            .get("min_options")
623            .and_then(|v| v.as_u64())
624            .unwrap_or(2) as usize;
625        let max_levels = get_usize("max_levels")?;
626        let isolated_levels = decision
627            .get("isolated_levels")
628            .and_then(|v| v.as_bool())
629            .unwrap_or(true);
630        let row_temp = decision
631            .get("isolated_row_temperature")
632            .and_then(|v| v.as_f64())
633            .filter(|v| v.is_finite() && *v > 0.0)
634            .ok_or_else(|| anyhow!("decision.json has no positive 'isolated_row_temperature'"))?;
635        let label_ids: Vec<u32> = decision
636            .get("labels")
637            .and_then(|l| l.get("ids"))
638            .and_then(|v| v.as_array())
639            .ok_or_else(|| anyhow!("decision.json has no 'labels.ids' array"))?
640            .iter()
641            .map(|v| {
642                v.as_u64()
643                    .filter(|&n| n <= u32::MAX as u64)
644                    .ok_or_else(|| anyhow!("decision.json label id {v} is not a token id"))
645                    .map(|n| n as u32)
646            })
647            .collect::<Result<_>>()?;
648        if label_ids.len() < max_options {
649            return Err(anyhow!(
650                "decision.json carries {} label ids for {max_options} option slots",
651                label_ids.len()
652            ));
653        }
654        let pad_id = decision
655            .get("special_tokens")
656            .and_then(|s| s.get("pad"))
657            .and_then(|v| v.as_u64())
658            .filter(|&n| n <= u32::MAX as u64)
659            .ok_or_else(|| anyhow!("decision.json has no 'special_tokens.pad' token id"))? as u32;
660        let temps = Decider2BTemperatures::load(calibration)?;
661        Ok(Self {
662            max_ctx,
663            max_options,
664            min_options,
665            max_levels,
666            isolated_levels,
667            row_temp,
668            label_ids,
669            pad_id,
670            temps,
671        })
672    }
673}
674
675pub struct Decider2B {
676    session: std::sync::RwLock<ort::session::Session>,
677    tokenizer: tokenizers::Tokenizer,
678    cfg: Decider2BConfig,
679    max_len: usize,
680    model_id: String,
681    used_cuda: bool,
682}
683
684impl Decider2B {
685    /// Open the registered decider-2b checkpoint (`Mapika/decider-2b`,
686    /// served from the `opticsWolf/decider-2b-fp32-onnx` self-export).
687    /// Fetches graph + vendored safetensors + tokenizer +
688    /// `decision.json` + `calibration.json` (the checkpoint truth:
689    /// ceilings, label alphabet, temperatures) from the artifact repo.
690    /// Unknown ids are refused — this export doesn't follow the legacy
691    /// file layout, so guessing would fetch garbage. Non-decider-2b
692    /// registry ids are refused with a pointer at `Decider`.
693    pub fn open(
694        model_id: &str,
695        revision: Option<&str>,
696        cache_dir: Option<PathBuf>,
697        device: DeviceReq,
698        max_length: Option<usize>,
699        precision: Precision,
700        cpu_arena: bool,
701    ) -> Result<Self> {
702        let spec = lookup_task(model_id).ok_or_else(|| {
703            anyhow!(
704                "unknown decider-2b model '{model_id}'; the registered checkpoint is Mapika/decider-2b (explicit files via open_files)"
705            )
706        })?;
707        if spec.kind != TaskKind::Decide2B {
708            return Err(anyhow!(
709                "'{model_id}' is a {} model, served by Decider/Reranker/LayaDecider — Decider2B serves the slot-layout Mapika/decider-2b checkpoint only",
710                spec.kind.as_str()
711            ));
712        }
713        let (_, used_cuda) = resolve_provider_names(device);
714        let precision = precision.resolve(used_cuda);
715        if precision != Precision::Fp32 && precision != Precision::Q8 {
716            eprintln!(
717                "embroider: decider-2b ships fp32+q8 weights — precision '{}' falls back to the fp32 artifact (the documented unlisted-pair rule, not a measured tier)",
718                precision.as_str()
719            );
720        }
721        let artifact = artifact_for_task(model_id, precision);
722
723        // ---- acquisition (artifact repo holds all five files) ----
724        let onnx_path =
725            fetch_repo_file(artifact.repo, artifact.file, revision, cache_dir.clone())?;
726        if let Some(sidecar) = artifact.sidecar() {
727            match fetch_repo_file(artifact.repo, &sidecar, revision, cache_dir.clone()) {
728                Ok(_) => {}
729                Err(e) => eprintln!("embroider: no {sidecar} sidecar; assuming inline weights ({e})"),
730            }
731        }
732        // q8 keeps embeddings/head/norms in the vendored safetensors —
733        // fetch it alongside the q8 sidecar (same repo, same revision).
734        if precision == Precision::Q8 {
735            fetch_repo_file(artifact.repo, "model.safetensors", revision, cache_dir.clone())?;
736        }
737        let tok_path =
738            fetch_repo_file(artifact.repo, "tokenizer.json", revision, cache_dir.clone())?;
739        let decision_path =
740            fetch_repo_file(artifact.repo, "decision.json", revision, cache_dir.clone())?;
741        let calib_path =
742            fetch_repo_file(artifact.repo, "calibration.json", revision, cache_dir)?;
743        Self::from_files(
744            &onnx_path,
745            &tok_path,
746            &decision_path,
747            &calib_path,
748            model_id,
749            device,
750            check_decider2b_limit_for(max_length, spec.max_tokens)?,
751            cpu_arena,
752        )
753    }
754
755    /// Load from explicit local files — no network access. The configs
756    /// carry the checkpoint truth (ceilings, label alphabet,
757    /// temperatures); they are required, not optional —
758    /// caller-supplied temperatures would silently fork the calibration.
759    pub fn open_files(
760        onnx_path: &Path,
761        tokenizer_path: &Path,
762        decision_path: &Path,
763        calibration_path: &Path,
764        device: DeviceReq,
765        max_length: Option<usize>,
766        cpu_arena: bool,
767    ) -> Result<Self> {
768        // Read the ceiling before the session: a caller cap above the
769        // checkpoint context fails here, not pages into a bench.
770        let decision_text = std::fs::read_to_string(decision_path).map_err(|e| {
771            anyhow!("decider-2b decision.json not found: {}: {e}", decision_path.display())
772        })?;
773        let decision: serde_json::Value = serde_json::from_str(&decision_text).map_err(|e| {
774            anyhow!("decider-2b decision.json parses: {}: {e}", decision_path.display())
775        })?;
776        let ceiling = decision
777            .get("max_ctx_tokens")
778            .and_then(|v| v.as_u64())
779            .filter(|&v| v > 0)
780            .ok_or_else(|| anyhow!("decision.json has no positive 'max_ctx_tokens'"))? as usize;
781        let max_length = check_decider2b_limit_for(max_length, ceiling)?;
782        Self::from_files(
783            onnx_path,
784            tokenizer_path,
785            decision_path,
786            calibration_path,
787            &onnx_path.display().to_string(),
788            device,
789            max_length,
790            cpu_arena,
791        )
792    }
793
794    #[allow(clippy::too_many_arguments)]
795    fn from_files(
796        onnx_path: &Path,
797        tokenizer_path: &Path,
798        decision_path: &Path,
799        calibration_path: &Path,
800        model_id: &str,
801        device: DeviceReq,
802        max_length: usize,
803        cpu_arena: bool,
804    ) -> Result<Self> {
805        if !onnx_path.is_file() {
806            return Err(anyhow!("onnx model not found: {}", onnx_path.display()));
807        }
808        if !tokenizer_path.is_file() {
809            return Err(anyhow!("tokenizer file not found: {}", tokenizer_path.display()));
810        }
811        let decision_text = std::fs::read_to_string(decision_path).map_err(|e| {
812            anyhow!("decider-2b decision.json not found: {}: {e}", decision_path.display())
813        })?;
814        let decision: serde_json::Value = serde_json::from_str(&decision_text).map_err(|e| {
815            anyhow!("decider-2b decision.json parses: {}: {e}", decision_path.display())
816        })?;
817        let calib_text = std::fs::read_to_string(calibration_path).map_err(|e| {
818            anyhow!("decider-2b calibration.json not found: {}: {e}", calibration_path.display())
819        })?;
820        let calibration: serde_json::Value =
821            serde_json::from_str(&calib_text).map_err(|e| {
822                anyhow!("decider-2b calibration.json parses: {}: {e}", calibration_path.display())
823            })?;
824        let cfg = Decider2BConfig::load(&decision, &calibration)?;
825        for r in &cfg.temps.rejections {
826            eprintln!("embroider: decider-2b temperature clamped [{r}] — treat the bucket as uncalibrated");
827        }
828        let tok = load_tokenizer(tokenizer_path, None)?;
829
830        // Own session build (not the shared `build_session`): this graph
831        // declares (input_ids, slot_pos) with no attention mask, and
832        // runs the parallel executor (see the module docs).
833        let (provider_names, used_cuda) = resolve_provider_names(device);
834        let mut builder = oe(ort::session::Session::builder())?;
835        builder = SessionPolicy::decider2b().apply(builder)?;
836        builder = apply_providers_with_arena(builder, &provider_names, cpu_arena);
837        let session = oe(builder.commit_from_file(onnx_path))
838            .map_err(|e| anyhow!("loading {}: {e:#}", onnx_path.display()))?;
839        let have: Vec<&str> = session.inputs().iter().map(|o| o.name()).collect();
840        for need in ["input_ids", "slot_pos"] {
841            if !have.iter().any(|&n| n == need) {
842                return Err(anyhow!(
843                    "ONNX export lacks required input '{need}' (has {have:?}) — not a decider-2b slot-layout graph"
844                ));
845            }
846        }
847        if !session.outputs().iter().any(|o| o.name() == "label_logits") {
848            let outs: Vec<&str> = session.outputs().iter().map(|o| o.name()).collect();
849            return Err(anyhow!(
850                "ONNX export lacks required output 'label_logits' (has {outs:?}) — not a decider-2b slot-layout graph"
851            ));
852        }
853        Ok(Self {
854            session: std::sync::RwLock::new(session),
855            tokenizer: tok,
856            cfg,
857            max_len: max_length,
858            model_id: model_id.to_string(),
859            used_cuda,
860        })
861    }
862    pub fn model_id(&self) -> &str {
863        &self.model_id
864    }
865
866    pub fn used_cuda(&self) -> bool {
867        self.used_cuda
868    }
869
870    /// Effective sequence cap.
871    pub fn max_len(&self) -> usize {
872        self.max_len
873    }
874
875    /// Checkpoint temperatures after the [0.05, 5.0] clamp.
876    pub fn temperatures(&self) -> [f64; 3] {
877        self.cfg.temps.triple
878    }
879
880    /// Load-time clamp rejections - buckets to treat as uncalibrated.
881    pub fn temperature_rejections(&self) -> &[String] {
882        &self.cfg.temps.rejections
883    }
884
885    /// Token count of a state without specials (the truncation-free
886    /// handle, same as `Decider::count_tokens`).
887    pub fn count_tokens(&self, state: &str) -> Result<usize> {
888        count_tokens_in(&self.tokenizer, state)
889    }
890
891    /// Build the input rows for (state, questions) without running
892    /// the graph: per question, its rows as `(input_ids, slot, k)`.
893    /// The inspection seam - lets the e2e pin the Rust construction
894    /// against the reference layout id-for-id.
895    pub fn encode_rows(
896        &self,
897        state: &str,
898        questions: &[Decider2BQuestion],
899    ) -> Result<Vec<(Vec<u32>, usize, usize)>> {
900        for (i, q) in questions.iter().enumerate() {
901            validate_decider2b_question(i, q, self.cfg.max_options, self.cfg.max_levels)?;
902        }
903        let full = self
904            .tokenizer
905            .encode(format!("Context:\n{state}").as_str(), false)
906            .map_err(|e| anyhow!("state encode: {e}"))?;
907        let ctx = full.get_ids().to_vec();
908        let mut out = Vec::new();
909        for q in questions {
910            let (new_rows, _) = encode_pair(
911                &self.tokenizer,
912                &ctx,
913                q,
914                &self.cfg.label_ids,
915                self.max_len,
916                self.cfg.isolated_levels,
917            )?;
918            for nr in new_rows {
919                out.push((nr.ids, nr.slot, nr.k));
920            }
921        }
922        Ok(out)
923    }
924
925    /// Answer questions over one state - one row per question through
926    /// batched session runs, order-preserving.
927    pub fn decide(
928        &self,
929        state: &str,
930        questions: &[Decider2BQuestion],
931    ) -> Result<Vec<Decider2BDecision>> {
932        let refs: Vec<(&str, &Decider2BQuestion)> =
933            questions.iter().map(|q| (state, q)).collect();
934        self.decide_many(&refs)
935    }
936
937    /// Answer (state, question) pairs - rows from every pair share the
938    /// batcher, one padded session run per batch, order-preserving.
939    pub fn decide_many(
940        &self,
941        items: &[(&str, &Decider2BQuestion)],
942    ) -> Result<Vec<Decider2BDecision>> {
943        for (i, (_, q)) in items.iter().enumerate() {
944            validate_decider2b_question(i, q, self.cfg.max_options, self.cfg.max_levels)?;
945        }
946        let ctx_each: Vec<Vec<u32>> = items
947            .iter()
948            .map(|(state, _)| {
949                let full = self
950                    .tokenizer
951                    .encode(format!("Context:\n{state}").as_str(), false)
952                    .map_err(|e| anyhow!("state encode: {e}"))?;
953                Ok(full.get_ids().to_vec())
954            })
955            .collect::<Result<_>>()?;
956        // Encode: rows accumulate globally, plan items rebase their row
957        // indices onto the shared list.
958        struct Row {
959            ids: Vec<u32>,
960            slot: usize,
961        }
962        let mut rows: Vec<Row> = Vec::new();
963        let mut plans: Vec<(PlanItem, usize)> =
964            Vec::with_capacity(items.len());
965        for (qi, (_, q)) in items.iter().enumerate() {
966            let base = rows.len();
967            let (new_rows, mut plan) = encode_pair(
968                &self.tokenizer,
969                &ctx_each[qi],
970                q,
971                &self.cfg.label_ids,
972                self.max_len,
973                self.cfg.isolated_levels,
974            )
975            .map_err(|e| anyhow!("question {qi}: {e:#}"))?;
976            for r in &mut plan.rows {
977                *r += base;
978            }
979            for nr in new_rows {
980                rows.push(Row { ids: nr.ids, slot: nr.slot });
981            }
982            plans.push((plan, qi));
983        }
984
985        // Collate: ort_rows packing, right-pad with the pad id (any id
986        // reaches no model - causal, padding trails the slot).
987        let lens: Vec<usize> = rows.iter().map(|r| r.ids.len()).collect();
988        let batches = pack_row_batches(&lens);
989        let mut outs: Vec<Vec<f32>> = vec![Vec::new(); rows.len()];
990        {
991            let mut guard =
992                self.session.write().map_err(|_| anyhow!("session lock poisoned"))?;
993            for (idx, l) in &batches {
994                let mut ids = Vec::with_capacity(idx.len() * l);
995                let mut slots = Vec::with_capacity(idx.len());
996                for &r in idx {
997                    ids.extend(rows[r].ids.iter().map(|&x| x as i64));
998                    ids.extend(
999                        std::iter::repeat(self.cfg.pad_id as i64)
1000                            .take(l - rows[r].ids.len()),
1001                    );
1002                    slots.push(rows[r].slot as i64);
1003                }
1004                let ids = Array2::from_shape_vec((idx.len(), *l), ids)
1005                    .map_err(|e| anyhow!("input shape: {e}"))?;
1006                let slots = ndarray::Array1::from_vec(slots);
1007                let ids_t = oe(ort::value::TensorRef::from_array_view(&ids))?;
1008                let slots_t = oe(ort::value::TensorRef::from_array_view(&slots))?;
1009                let outputs = oe(guard.run(ort::inputs! {
1010                    "input_ids" => ids_t,
1011                    "slot_pos" => slots_t
1012                }))?;
1013                let logits = oe(outputs["label_logits"].try_extract_array::<f32>())?
1014                    .to_owned()
1015                    .into_dimensionality::<ndarray::Ix2>()
1016                    .map_err(|e| anyhow!("expected [rows, 255] label_logits: {e}"))?;
1017                if logits.shape() != [idx.len(), 255] {
1018                    return Err(anyhow!(
1019                        "label_logits shape {:?} does not match {} rows x 255 labels",
1020                        logits.shape(),
1021                        idx.len()
1022                    ));
1023                }
1024                for (b, &r) in idx.iter().enumerate() {
1025                    outs[r] = logits.row(b).iter().copied().collect();
1026                }
1027            }
1028        }
1029        let mut out = Vec::with_capacity(items.len());
1030        for (plan, qi) in &plans {
1031            let logits = option_logits(plan, &outs, self.cfg.row_temp);
1032            let (_, q) = items[*qi];
1033            out.push(decode_decider2b_row(&logits, q, &self.cfg.temps.triple));
1034        }
1035        Ok(out)
1036    }
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042
1043    #[test]
1044    fn strip_level_number_removes_numeric_prefix() {
1045        assert_eq!(strip_level_number("0: low"), "low");
1046        assert_eq!(strip_level_number("  12: answer text "), "answer text ");
1047        assert_eq!(strip_level_number("-3: neg"), "neg");
1048        assert_eq!(strip_level_number("plain level"), "plain level");
1049        assert_eq!(strip_level_number("A: lettered"), "A: lettered");
1050        assert_eq!(strip_level_number("10x: not a number prefix"), "10x: not a number prefix");
1051    }
1052
1053    #[test]
1054    fn clamp_floor_is_checkpoint_range() {
1055        // The fitted noul temperature IS 0.05: Laya's 0.5 floor would
1056        // silently corrupt it. Range from calibration.json.
1057        assert_eq!(clamp_decider2b_temperature(2.465), 2.465);
1058        assert_eq!(clamp_decider2b_temperature(0.05), 0.05);
1059        assert_eq!(clamp_decider2b_temperature(0.049), 0.05);
1060        assert_eq!(clamp_decider2b_temperature(9.0), 5.0);
1061        assert_eq!(clamp_decider2b_temperature(f64::NAN), 1.0);
1062        assert_eq!(clamp_decider2b_temperature(f64::INFINITY), 1.0);
1063        let raw = serde_json::json!(0.05);
1064        assert!(!temperature_rejected(&raw, 0.05));
1065        assert!(temperature_rejected(&raw, 0.5));
1066    }
1067
1068    #[test]
1069    fn batch_packing_matches_ort_rows() {
1070        // ort_rows: sorted by len, extend while L2*(count) <= 8192.
1071        assert_eq!(pack_row_batches(&[100, 100, 100]), vec![(vec![0, 1, 2], 128)]);
1072        // One long row forces its own batch at its own ceiling.
1073        let got = pack_row_batches(&[100, 9000]);
1074        assert_eq!(got.len(), 2);
1075        assert_eq!(got[1], (vec![1], 9024));
1076        // Full-budget boundary: sorted, packed tight.
1077        let lens = vec![8000, 100, 100];
1078        let got = pack_row_batches(&lens);
1079        assert_eq!(got[0].0, vec![1, 2]);
1080        assert_eq!(got[0].1, 128);
1081        assert_eq!(got[1].0, vec![0]);
1082    }
1083
1084    #[test]
1085    fn isolated_option_logits_are_log_sigmoids() {
1086        // Two levels, row_temp 2.833 (the checkpoint value): z_j =
1087        // log_sigmoid((yes-no)/T). Softmax over z == normalised p_yes.
1088        let item = PlanItem { qtype: 1, kind: RowKind::Iso, rows: vec![0, 1], k: 2 };
1089        let outs = vec![vec![0.0f32; 255], vec![0.0f32; 255]];
1090        let mut o0 = outs[0].clone();
1091        let mut o1 = outs[1].clone();
1092        o0[0] = -1.0;
1093        o0[1] = 1.0;
1094        o1[0] = 0.5;
1095        o1[1] = -0.5;
1096        let z = option_logits(&item, &[o0, o1], 2.833);
1097        assert_eq!(z.len(), 2);
1098        let s0 = 1.0 / (1.0 + (-(2.0f64) / 2.833).exp());
1099        let s1 = 1.0 / (1.0 + (-(-1.0f64) / 2.833).exp());
1100        assert!((z[0] - s0.ln()).abs() < 1e-12, "{}", z[0]);
1101        assert!((z[1] - s1.ln()).abs() < 1e-12, "{}", z[1]);
1102        let item = PlanItem { qtype: 0, kind: RowKind::List, rows: vec![0], k: 3 };
1103        let mut oo = vec![0.0f32; 255];
1104        oo[0] = 1.0;
1105        oo[1] = 2.0;
1106        oo[2] = 3.0;
1107        assert_eq!(option_logits(&item, &[oo], 1.0), vec![1.0, 2.0, 3.0]);
1108    }
1109
1110    #[test]
1111    fn billing_style_temperature_inversion() {
1112        // parity3 billing: triple [2.465, 1.0, 0.05]; invert a known
1113        // distribution through the choice temperature and recover it.
1114        let t = 2.465f64;
1115        let want = [0.9231f64, 0.048f64, 0.0289f64];
1116        let logits: Vec<f64> = want.iter().map(|&p| p.ln() * t).collect();
1117        let q = Decider2BQuestion::Choice {
1118            instructions: "route".to_string(),
1119            options: vec![
1120                Decider2BOption::plain("billing"),
1121                Decider2BOption::plain("technical support"),
1122                Decider2BOption::plain("sales"),
1123            ],
1124        };
1125        let d = decode_decider2b_row(&logits, &q, &[t, 1.0, 0.05]);
1126        assert_eq!(d.label, "billing");
1127        for (i, (_, p)) in d.probabilities.iter().enumerate() {
1128            assert!((*p as f64 - want[i]).abs() < 1e-4, "{p} vs {}", want[i]);
1129        }
1130    }
1131
1132    #[test]
1133    fn noul_confidence_is_two_sided() {
1134        let q = Decider2BQuestion::Noul {
1135            instructions: "holds?".to_string(),
1136            true_criterion: "yes".to_string(),
1137            false_criterion: String::new(),
1138        };
1139        // Bare false renders "no", true renders "yes: yes".
1140        assert_eq!(
1141            render_decider_options(&q),
1142            vec!["no".to_string(), "yes: yes".to_string()]
1143        );
1144        let d = decode_decider2b_row(&[-1.0, 1.0], &q, &[2.465, 1.0, 0.05]);
1145        assert_eq!(d.label, "true");
1146        assert!(d.confidence > 0.99, "{}", d.confidence);
1147    }
1148
1149    #[test]
1150    fn validation_fires_before_io() {
1151        let bad = Decider2BQuestion::Choice {
1152            instructions: "  ".to_string(),
1153            options: vec![Decider2BOption::plain("a"), Decider2BOption::plain("b")],
1154        };
1155        assert!(validate_decider2b_question(0, &bad, 255, 10)
1156            .err()
1157            .unwrap()
1158            .to_string()
1159            .contains("must not be empty"));
1160        let bad = Decider2BQuestion::Choice {
1161            instructions: "x".to_string(),
1162            options: vec![Decider2BOption::plain("a")],
1163        };
1164        assert!(validate_decider2b_question(1, &bad, 255, 10)
1165            .err()
1166            .unwrap()
1167            .to_string()
1168            .contains("at least 2"));
1169        let bad = Decider2BQuestion::Choice {
1170            instructions: "x".to_string(),
1171            options: vec![Decider2BOption::plain("a"), Decider2BOption::plain("a")],
1172        };
1173        assert!(validate_decider2b_question(2, &bad, 255, 10)
1174            .err()
1175            .unwrap()
1176            .to_string()
1177            .contains("duplicate"));
1178        let bad = Decider2BQuestion::Score { instructions: "x".to_string(), levels: vec![] };
1179        assert!(validate_decider2b_question(3, &bad, 255, 10)
1180            .err()
1181            .unwrap()
1182            .to_string()
1183            .contains("at least 2"));
1184        let ok = Decider2BQuestion::Noul {
1185            instructions: "x".to_string(),
1186            true_criterion: String::new(),
1187            false_criterion: String::new(),
1188        };
1189        assert!(validate_decider2b_question(4, &ok, 255, 10).is_ok());
1190    }
1191
1192    #[test]
1193    fn open_files_rejects_missing_config_before_session() {
1194        let e = Decider2B::open_files(
1195            Path::new("/nonexistent/model.onnx"),
1196            Path::new("/nonexistent/tokenizer.json"),
1197            Path::new("/nonexistent/decision.json"),
1198            Path::new("/nonexistent/calibration.json"),
1199            DeviceReq::Cpu,
1200            None,
1201            false,
1202        )
1203        .err()
1204        .expect("open_files should fail")
1205        .to_string();
1206        assert!(e.contains("decision.json not found"), "{e}");
1207    }
1208}