1use 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#[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#[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#[derive(Clone, Debug, PartialEq)]
104pub struct Decider2BDecision {
105 pub label: String,
108 pub probabilities: Vec<(String, f32)>,
111 pub value: Option<f32>,
113 pub confidence: f32,
116 pub answer_confidence: f32,
118}
119
120pub(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#[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
172const NARROW: usize = 10;
176const LETTERS: &[u8] = b"ABCDEFGHIJ";
177const ISOLATED_Q: &str = "{q}\nProposed answer: {level}\nDoes the proposed answer fit?";
181const ROW_BUDGET: usize = 8192;
184const SEQ_MULTIPLE: usize = 64;
185
186pub(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
205fn 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
238fn 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
300fn 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
359struct EncRow {
363 ids: Vec<u32>,
364 slot: usize,
365 k: usize,
366}
367
368fn 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
388fn 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 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
448fn 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
475pub(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#[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 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#[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 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 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 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 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 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 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 pub fn max_len(&self) -> usize {
872 self.max_len
873 }
874
875 pub fn temperatures(&self) -> [f64; 3] {
877 self.cfg.temps.triple
878 }
879
880 pub fn temperature_rejections(&self) -> &[String] {
882 &self.cfg.temps.rejections
883 }
884
885 pub fn count_tokens(&self, state: &str) -> Result<usize> {
888 count_tokens_in(&self.tokenizer, state)
889 }
890
891 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 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 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 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 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 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 assert_eq!(pack_row_batches(&[100, 100, 100]), vec![(vec![0, 1, 2], 128)]);
1072 let got = pack_row_batches(&[100, 9000]);
1074 assert_eq!(got.len(), 2);
1075 assert_eq!(got[1], (vec![1], 9024));
1076 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 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 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 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}