1use std::path::{Path, PathBuf};
5
6use candle_core::Device;
7use indexmap::IndexMap;
8use serde_json::{json, Map, Value};
9
10use crate::common::{
11 answer_confidence, build_sequence, clamp_temperature, collate_items, confidence_from_probs,
12 render_criterion, render_options, round4, softmax, temp_bucket, unpermute_probs, InternalQ,
13 Item, QType,
14};
15use crate::error::{LayaError, Result};
16use crate::model::DecisionModel;
17use crate::tokenizer::LayaTokenizer;
18use crate::{Questions, State};
19
20#[derive(Debug, Clone, Default)]
22pub struct LoadOptions {
23 pub device: Option<String>,
25 pub token: Option<String>,
27 pub subfolder: Option<String>,
29}
30
31#[derive(Debug, Clone)]
33pub struct SystemOneResult {
34 pub model: String,
35 pub answers: IndexMap<String, Value>,
36 pub input_tokens: usize,
37 pub output_tokens: usize,
38 pub routing: Option<Value>,
40}
41
42impl SystemOneResult {
43 pub fn to_json(&self) -> Value {
45 let answers: Map<String, Value> = self
46 .answers
47 .iter()
48 .map(|(k, v)| (k.clone(), v.clone()))
49 .collect();
50 let mut out = json!({
51 "model": self.model,
52 "answers": Value::Object(answers),
53 "usage": {"input_tokens": self.input_tokens, "output_tokens": self.output_tokens},
54 });
55 if let Some(r) = &self.routing {
56 out.as_object_mut()
57 .unwrap()
58 .insert("routing".to_string(), r.clone());
59 }
60 out
61 }
62}
63
64struct CheckpointFiles {
65 config: PathBuf,
66 weights: PathBuf,
67 encoder_config: PathBuf,
68 tokenizer: PathBuf,
69}
70
71pub struct Agent {
73 model: DecisionModel,
74 tok: LayaTokenizer,
75 max_len: usize,
76 head_max_len: usize,
77 temperature: [f64; 3],
78 temperature_by_options: std::collections::HashMap<String, f64>,
79}
80
81impl Agent {
82 pub fn load(model_id_or_path: &str, opts: LoadOptions) -> Result<Agent> {
84 let files = resolve_files(model_id_or_path, &opts)?;
85
86 let cfg: Value = serde_json::from_slice(&std::fs::read(&files.config)?)?;
87 let encoder_config: Value = serde_json::from_slice(&std::fs::read(&files.encoder_config)?)?;
88
89 let max_len = cfg.get("max_len").and_then(|v| v.as_u64()).unwrap_or(512) as usize;
90 let head_max_len = cfg
91 .get("head_max_len")
92 .and_then(|v| v.as_u64())
93 .unwrap_or(192) as usize;
94 let head_layers = cfg.get("head_layers").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
95 let n_act = cfg
96 .get("act_costs")
97 .and_then(|v| v.as_object())
98 .map(|m| m.len())
99 .unwrap_or(0)
100 + 1;
101
102 let temperature = load_temperature(&cfg);
103 let temperature_by_options = load_temperature_by_options(&cfg);
104
105 let device = resolve_device(opts.device.as_deref());
106 let model =
107 DecisionModel::load(&files.weights, &encoder_config, head_layers, n_act, device)?;
108 let tok = LayaTokenizer::from_file(&files.tokenizer)?;
109
110 Ok(Agent {
111 model,
112 tok,
113 max_len,
114 head_max_len,
115 temperature,
116 temperature_by_options,
117 })
118 }
119
120 pub fn system_one(&self, state: &State, questions: &Questions) -> Result<SystemOneResult> {
122 Ok(self
123 .predict_batch(std::slice::from_ref(state), questions, None)?
124 .pop()
125 .expect("one state → one result"))
126 }
127
128 pub fn predict(&self, state: &State, questions: &Questions) -> Result<SystemOneResult> {
130 self.system_one(state, questions)
131 }
132
133 pub fn predict_batch(
135 &self,
136 states: &[State],
137 questions: &Questions,
138 batch_size: Option<usize>,
139 ) -> Result<Vec<SystemOneResult>> {
140 if states.is_empty() {
141 return Ok(Vec::new());
142 }
143 let ids: Vec<String> = questions.keys().cloned().collect();
144 if ids.is_empty() {
145 return Ok(states
146 .iter()
147 .map(|_| SystemOneResult {
148 model: "laya-rl-agent".to_string(),
149 answers: IndexMap::new(),
150 input_tokens: 0,
151 output_tokens: 0,
152 routing: None,
153 })
154 .collect());
155 }
156
157 let mut internal: Vec<(String, InternalQ)> = Vec::with_capacity(ids.len());
159 for qid in &ids {
160 let qdef = &questions[qid];
161 check_question(qid, qdef)?;
162 internal.push((qid.clone(), to_internal(qdef)?));
163 }
164
165 let chunk = batch_size.filter(|&b| b > 0).unwrap_or(states.len());
166 let mut results = Vec::with_capacity(states.len());
167
168 for part in states.chunks(chunk) {
169 let mut per_state_items: Vec<Vec<Item>> = Vec::with_capacity(part.len());
170 for st in part {
171 per_state_items.push(self.encode_state(st, &internal)?);
172 }
173 let batch = collate_items(&per_state_items, self.tok_pad())
174 .ok_or_else(|| LayaError::Model("empty batch".to_string()))?;
175 let (logits, act) = self.model.forward(&batch)?;
176
177 let mut row = 0usize;
178 for items in &per_state_items {
179 let nrows = items.len();
180 let n_tokens: usize = (row..row + nrows)
181 .map(|r| {
182 batch.attention_mask[r]
183 .iter()
184 .map(|&x| x as usize)
185 .sum::<usize>()
186 })
187 .sum();
188 let answers = self.decode(&logits, &act, items, &internal, row);
189 results.push(SystemOneResult {
190 model: "laya-rl-agent".to_string(),
191 answers,
192 input_tokens: n_tokens,
193 output_tokens: 0,
194 routing: None,
195 });
196 row += nrows;
197 }
198 }
199 Ok(results)
200 }
201
202 fn tok_pad(&self) -> u32 {
203 use crate::common::Tokenizer;
204 self.tok.pad_id()
205 }
206
207 pub fn embed(
214 &self,
215 texts: &[String],
216 max_length: usize,
217 batch_size: usize,
218 ) -> Result<Vec<Vec<f32>>> {
219 if texts.is_empty() {
220 return Ok(Vec::new());
221 }
222 let pad = self.tok_pad();
223 let mut out = Vec::with_capacity(texts.len());
224 for chunk in texts.chunks(batch_size.max(1)) {
225 let rows: Vec<Vec<u32>> = chunk
226 .iter()
227 .map(|t| self.tok.encode_with_special(t, max_length))
228 .collect();
229 let l = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
230 let mut ids = Vec::with_capacity(rows.len());
231 let mut att = Vec::with_capacity(rows.len());
232 for r in &rows {
233 let mut row = r.clone();
234 let mut a = vec![1u32; row.len()];
235 row.resize(l, pad);
236 a.resize(l, 0);
237 ids.push(row);
238 att.push(a);
239 }
240 out.extend(self.model.embed(&ids, &att)?);
241 }
242 Ok(out)
243 }
244
245 fn encode_state(&self, state: &State, internal: &[(String, InternalQ)]) -> Result<Vec<Item>> {
246 let truncate_left = state.is_array();
247 let mut items = Vec::with_capacity(internal.len());
248 for (qid, q) in internal {
249 let (seq, markers) = build_sequence(
250 &self.tok,
251 state,
252 q,
253 self.max_len,
254 self.head_max_len,
255 q.option_order.as_deref(),
256 truncate_left,
257 )?;
258 if markers.len() != render_options(q)?.len() {
259 return Err(LayaError::InvalidQuestion(format!(
260 "question {:?} options exceed head_max_len={}",
261 qid, self.head_max_len
262 )));
263 }
264 items.push(Item {
265 ids: seq,
266 markers,
267 qtype: q.t.index(),
268 });
269 }
270 Ok(items)
271 }
272
273 fn decode(
274 &self,
275 logits: &[Vec<f32>],
276 act: &[Vec<f32>],
277 items: &[Item],
278 internal: &[(String, InternalQ)],
279 offset: usize,
280 ) -> IndexMap<String, Value> {
281 let mut answers = IndexMap::new();
282 for (j, (qid, q)) in internal.iter().enumerate() {
283 let r = offset + j;
284 let k = items[j].markers.len();
285 let qt = q.t.index();
286 let t_scale = self
287 .temperature_by_options
288 .get(&temp_bucket(qt, k))
289 .copied()
290 .unwrap_or(self.temperature[qt]);
291
292 let z: Vec<f64> = logits[r][..k].iter().map(|&v| v as f64 / t_scale).collect();
293 let p = softmax(&z);
294 let p = unpermute_probs(&p, q.option_order.as_deref());
297 let conf = round4(confidence_from_probs(&p, k));
298 let ans_conf = round4(answer_confidence(&p, k));
303
304 let act_p = softmax(&act[r].iter().map(|&v| v as f64).collect::<Vec<_>>());
305 let act_probability = round4(act_p.first().copied().unwrap_or(0.0));
306 let action = json!({ "act_probability": act_probability });
307
308 let answer = match q.t {
309 QType::Choice => {
310 let keys: Vec<String> = q
311 .crit
312 .as_object()
313 .map(|m| m.keys().cloned().collect())
314 .unwrap_or_default();
315 let best = argmax(&p);
316 let probs: Map<String, Value> = keys
317 .iter()
318 .zip(p.iter())
319 .map(|(kk, &v)| (kk.clone(), json!(round4(v))))
320 .collect();
321 json!({
322 "type": "choice",
323 "choice": keys.get(best).cloned().unwrap_or_default(),
324 "probabilities": Value::Object(probs),
325 "confidence": conf,
326 "answer_confidence": ans_conf,
327 "action": action,
328 })
329 }
330 QType::Score => {
331 let exp: f64 = p.iter().enumerate().map(|(i, &v)| i as f64 * v).sum();
332 let legend: Map<String, Value> = q
338 .crit
339 .as_array()
340 .map(|a| {
341 a.iter()
342 .enumerate()
343 .map(|(i, c)| (i.to_string(), Value::String(render_criterion(c))))
344 .collect()
345 })
346 .unwrap_or_default();
347 let probs: Map<String, Value> = p
348 .iter()
349 .enumerate()
350 .map(|(i, &v)| (i.to_string(), json!(round4(v))))
351 .collect();
352 json!({
353 "type": "score",
354 "score": round4(exp),
355 "legend": Value::Object(legend),
356 "probabilities": Value::Object(probs),
357 "confidence": conf,
358 "answer_confidence": ans_conf,
359 "action": action,
360 })
361 }
362 QType::Noul => {
363 let p_true = p.get(1).copied().unwrap_or(0.0);
364 json!({
365 "type": "noul",
366 "noul": round4(p_true),
367 "confidence": round4(p_true.max(1.0 - p_true)),
368 "answer_confidence": ans_conf,
370 "action": action,
371 })
372 }
373 };
374 answers.insert(qid.clone(), answer);
375 }
376 answers
377 }
378}
379
380fn argmax(p: &[f64]) -> usize {
381 let mut best = 0usize;
382 for i in 1..p.len() {
383 if p[i] > p[best] {
384 best = i;
385 }
386 }
387 best
388}
389
390fn load_temperature(cfg: &Value) -> [f64; 3] {
391 let arr = cfg.get("temperature").and_then(|v| v.as_array());
392 let mut out = [1.0; 3];
393 if let Some(a) = arr {
394 for (i, slot) in out.iter_mut().enumerate() {
395 if let Some(v) = a.get(i) {
396 *slot = clamp_temperature(v);
397 }
398 }
399 }
400 out
401}
402
403fn load_temperature_by_options(cfg: &Value) -> std::collections::HashMap<String, f64> {
404 cfg.get("temperature_by_options")
405 .and_then(|v| v.as_object())
406 .map(|m| {
407 m.iter()
408 .map(|(k, v)| (k.clone(), clamp_temperature(v)))
409 .collect()
410 })
411 .unwrap_or_default()
412}
413
414fn resolve_device(device: Option<&str>) -> Device {
415 match device.map(|d| d.trim().to_lowercase()).as_deref() {
416 Some("metal") => {
417 #[cfg(feature = "metal")]
418 {
419 match Device::new_metal(0) {
420 Ok(d) => d,
421 Err(e) => {
422 eprintln!(
423 "laya: Metal requested but unavailable ({e}); falling back to CPU."
424 );
425 Device::Cpu
426 }
427 }
428 }
429 #[cfg(not(feature = "metal"))]
430 {
431 eprintln!(
432 "laya: device=\"metal\" requested but the crate was built without the `metal` \
433 feature; using CPU. Rebuild with --features metal."
434 );
435 Device::Cpu
436 }
437 }
438 _ => Device::Cpu,
440 }
441}
442
443fn check_question(qid: &str, qdef: &Value) -> Result<()> {
445 if qid.trim().is_empty() {
449 return Err(LayaError::InvalidQuestion(format!(
450 "question id must be a non-empty string, got {:?}",
451 qid
452 )));
453 }
454 let obj = match qdef {
455 Value::Object(m) => m,
456 other => {
457 let kind = match other {
458 Value::Array(_) => "list",
459 Value::Null => "NoneType",
460 Value::String(_) => "str",
461 Value::Bool(_) => "bool",
462 Value::Number(_) => "number",
463 Value::Object(_) => unreachable!(),
464 };
465 return Err(LayaError::InvalidQuestion(format!(
466 "question {:?}: definition must be a dict, got {}",
467 qid, kind
468 )));
469 }
470 };
471 let t = obj.get("type").and_then(|v| v.as_str());
472 let t = match t.and_then(QType::from_name) {
473 Some(t) => t,
474 None => {
475 return Err(LayaError::InvalidQuestion(format!(
476 "question {:?}: unknown type {:?}; use one of [\"choice\", \"noul\", \"score\"]",
477 qid,
478 obj.get("type").unwrap_or(&Value::Null)
479 )))
480 }
481 };
482 if !obj.contains_key("instructions") {
483 return Err(LayaError::InvalidQuestion(format!(
484 "question {:?}: no 'instructions'; add the text the model should answer",
485 qid
486 )));
487 }
488 match obj.get("instructions") {
492 Some(Value::Null) => {
493 return Err(LayaError::InvalidQuestion(format!(
494 "question {:?}: 'instructions' must not be None; add the text the model should answer",
495 qid
496 )));
497 }
498 Some(Value::String(s)) if s.trim().is_empty() => {
499 return Err(LayaError::InvalidQuestion(format!(
500 "question {:?}: 'instructions' must not be empty; add the text the model should answer",
501 qid
502 )));
503 }
504 Some(Value::Array(a)) if a.is_empty() => {
505 return Err(LayaError::InvalidQuestion(format!(
506 "question {:?}: 'instructions' must not be empty; add the text the model should answer",
507 qid
508 )));
509 }
510 Some(Value::Object(m)) if m.is_empty() => {
511 return Err(LayaError::InvalidQuestion(format!(
512 "question {:?}: 'instructions' must not be empty; add the text the model should answer",
513 qid
514 )));
515 }
516 _ => {}
517 }
518 let crit = obj.get("criteria");
519 match t {
520 QType::Choice => {
521 let ok = matches!(crit, Some(Value::Object(_)) | Some(Value::Array(_)));
522 if !ok {
523 return Err(LayaError::InvalidQuestion(format!(
524 "question {:?}: a choice question takes 'criteria' as a dict of label -> description, or a list of labels",
525 qid
526 )));
527 }
528 let empty = match crit {
529 Some(Value::Object(m)) => m.is_empty(),
530 Some(Value::Array(a)) => a.is_empty(),
531 _ => true,
532 };
533 if empty {
534 return Err(LayaError::InvalidQuestion(format!(
535 "question {:?}: a choice question needs at least one criterion",
536 qid
537 )));
538 }
539 if let Some(Value::Array(a)) = crit {
545 let mut seen: std::collections::HashMap<String, usize> =
552 std::collections::HashMap::new();
553 for (i, label) in a.iter().enumerate() {
554 let kind = match label {
555 Value::Array(_) => "list",
556 Value::Object(_) => "object",
557 Value::Null => {
562 return Err(LayaError::InvalidQuestion(format!(
563 "question {:?}: choice label {} is null; a label is rendered as \
564 option text and used as the answer key, so it must be a string, \
565 number or bool",
566 qid, i
567 )));
568 }
569 _ => {
570 let key = label
571 .as_str()
572 .map(|s| s.to_string())
573 .unwrap_or_else(|| crate::common::py_json(label));
574 if let Some(&first) = seen.get(&key) {
575 return Err(LayaError::InvalidQuestion(format!(
576 "question {:?}: choice label {} ({}) repeats label {}; the \
577 labels are the answer keys, so every option needs its own",
578 qid,
579 i,
580 crate::common::py_json(label),
581 first
582 )));
583 }
584 seen.insert(key, i);
585 continue;
586 }
587 };
588 return Err(LayaError::InvalidQuestion(format!(
589 "question {:?}: choice label {} is a {}; a label is rendered as option text \
590 and used as the answer key, so it must be a scalar (a string, number or \
591 bool), got {}",
592 qid,
593 i,
594 kind,
595 crate::common::py_json(label)
596 )));
597 }
598 }
599 }
600 QType::Score => {
601 let arr = match crit {
602 Some(Value::Array(a)) => a,
603 _ => {
604 return Err(LayaError::InvalidQuestion(format!(
605 "question {:?}: a score question takes 'criteria' as a list of level descriptions, index 0 first",
606 qid
607 )))
608 }
609 };
610 if arr.is_empty() {
611 return Err(LayaError::InvalidQuestion(format!(
612 "question {:?}: a score question needs at least one level",
613 qid
614 )));
615 }
616 if let Some(idx) = arr.iter().position(|v| v.is_null()) {
619 return Err(LayaError::InvalidQuestion(format!(
620 "question {:?}: score level {} is null; give every level a description, index 0 first",
621 qid, idx
622 )));
623 }
624 }
625 QType::Noul => {
626 if let Some(c) = crit {
627 if !c.is_null() && !c.is_object() {
628 return Err(LayaError::InvalidQuestion(format!(
629 "question {:?}: a noul question takes 'criteria' as a dict with optional 'true'/'false' descriptions, or omits it",
630 qid
631 )));
632 }
633 if let Some(map) = c.as_object() {
637 let mut keys: Vec<String> = map.keys().map(|k| k.to_lowercase()).collect();
638 if keys.iter().any(|k| k != "true" && k != "false") {
639 keys.sort();
640 let shown: Vec<Value> = keys.into_iter().map(Value::String).collect();
641 return Err(LayaError::InvalidQuestion(format!(
642 "question {:?}: a noul question takes 'criteria' keyed only 'true'/'false' (either or both, and omitted is fine), got {}. Those keys are the option texts the model reads; any other key was silently dropped and replaced with the defaults. If you want the answer worded differently, keep 'criteria' keyed 'true'/'false' and set 'labels' instead.",
643 qid,
644 Value::Array(shown)
645 )));
646 }
647 }
648 }
649 }
650 }
651 if let Some(order) = obj.get("option_order") {
652 let n = option_count(t, crit);
658 let ok = match order {
659 Value::Array(a) => {
660 let mut idxs: Vec<i64> = a
661 .iter()
662 .filter_map(|v| match v {
663 Value::Number(num) => num.as_i64(),
664 _ => None,
665 })
666 .collect();
667 idxs.sort_unstable();
668 a.len() == n && idxs == (0..n as i64).collect::<Vec<_>>()
669 }
670 _ => false,
671 };
672 if !ok {
673 return Err(LayaError::InvalidQuestion(format!(
674 "question {:?}: 'option_order' must be a permutation of range({}) -- one slot per \
675 option, each option once -- got {}",
676 qid,
677 n,
678 crate::common::py_json(order)
679 )));
680 }
681 }
682 if obj.contains_key("labels") {
683 if t != QType::Noul {
684 return Err(LayaError::InvalidQuestion(format!(
685 "question {:?}: 'labels' is only supported for noul questions",
686 qid
687 )));
688 }
689 crate::common::resolve_noul_labels(obj.get("labels"))
690 .map_err(|e| LayaError::InvalidQuestion(format!("question {:?}: {}", qid, e)))?;
691 }
692 Ok(())
693}
694
695fn option_count(t: QType, crit: Option<&Value>) -> usize {
699 match t {
700 QType::Noul => 2,
701 _ => match crit {
702 Some(Value::Object(m)) => m.len(),
703 Some(Value::Array(a)) => a.len(),
704 _ => 0,
705 },
706 }
707}
708
709fn to_internal(qdef: &Value) -> Result<InternalQ> {
711 let obj = qdef.as_object().expect("validated as object");
712 let t = QType::from_name(obj.get("type").and_then(|v| v.as_str()).unwrap_or("")).unwrap();
713 let mut crit = obj.get("criteria").cloned().unwrap_or(Value::Null);
714 match t {
715 QType::Choice => {
716 if let Value::Array(a) = &crit {
717 let mut m = Map::new();
718 for c in a {
719 let key = c
720 .as_str()
721 .map(|s| s.to_string())
722 .unwrap_or_else(|| crate::common::py_json(c));
723 m.insert(key, Value::Null);
724 }
725 crit = Value::Object(m);
726 }
727 }
728 QType::Noul => {
729 if let Value::Object(m) = &crit {
730 let lowered: Map<String, Value> = m
731 .iter()
732 .map(|(k, v)| (k.to_lowercase(), v.clone()))
733 .collect();
734 crit = Value::Object(lowered);
735 }
736 }
737 QType::Score => {}
738 }
739 let ins = match obj.get("instructions") {
740 Some(Value::String(s)) => s.clone(),
741 Some(other) => crate::common::py_json(other),
742 None => String::new(),
743 };
744 let labels = obj.get("labels").cloned();
745 let option_order = obj.get("option_order").and_then(|v| v.as_array()).map(|a| {
747 a.iter()
748 .filter_map(|i| i.as_u64())
749 .map(|i| i as usize)
750 .collect()
751 });
752 Ok(InternalQ {
753 t,
754 ins,
755 crit,
756 labels,
757 option_order,
758 })
759}
760
761fn resolve_files(model_id_or_path: &str, opts: &LoadOptions) -> Result<CheckpointFiles> {
763 let base = Path::new(model_id_or_path);
764 if base.exists() {
765 let dir = match &opts.subfolder {
766 Some(sub) => base.join(sub),
767 None => base.to_path_buf(),
768 };
769 let config = require(dir.join("rl_agent_config.json"))?;
770 let weights = require(dir.join("model.safetensors"))?;
771 let encoder_config = if dir.join("encoder/config.json").exists() {
772 dir.join("encoder/config.json")
773 } else {
774 require(dir.join("config.json"))?
775 };
776 let tokenizer = if dir.join("tokenizer/tokenizer.json").exists() {
777 dir.join("tokenizer/tokenizer.json")
778 } else {
779 require(dir.join("tokenizer.json"))?
780 };
781 return Ok(CheckpointFiles {
782 config,
783 weights,
784 encoder_config,
785 tokenizer,
786 });
787 }
788 if model_id_or_path.starts_with('/')
789 || model_id_or_path.starts_with("./")
790 || model_id_or_path.starts_with("../")
791 {
792 return Err(LayaError::NotFound(format!(
793 "Local model path not found: {:?}.",
794 model_id_or_path
795 )));
796 }
797 download_files(model_id_or_path, opts)
798}
799
800fn require(p: PathBuf) -> Result<PathBuf> {
801 if p.exists() {
802 Ok(p)
803 } else {
804 Err(LayaError::NotFound(format!(
805 "missing checkpoint file: {}",
806 p.display()
807 )))
808 }
809}
810
811fn download_files(repo: &str, opts: &LoadOptions) -> Result<CheckpointFiles> {
812 use hf_hub::api::sync::ApiBuilder;
813 let token = opts
816 .token
817 .clone()
818 .or_else(|| std::env::var("HF_TOKEN").ok())
819 .filter(|t| !t.is_empty());
820 let api = ApiBuilder::new()
821 .with_token(token)
822 .build()
823 .map_err(|e| LayaError::Download(e.to_string()))?;
824 let repo_api = api.model(repo.to_string());
825 let prefix = match &opts.subfolder {
826 Some(sub) => format!("{}/", sub),
827 None => String::new(),
828 };
829 let get = |name: &str| -> Result<PathBuf> {
830 repo_api
831 .get(&format!("{}{}", prefix, name))
832 .map_err(|e| LayaError::Download(format!("{}{}: {}", prefix, name, e)))
833 };
834 let config = get("rl_agent_config.json")?;
835 let weights = get("model.safetensors")?;
836 let encoder_config = get("encoder/config.json")?;
837 let tokenizer = get("tokenizer/tokenizer.json").or_else(|_| get("tokenizer.json"))?;
838 Ok(CheckpointFiles {
839 config,
840 weights,
841 encoder_config,
842 tokenizer,
843 })
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849
850 #[test]
851 fn rejects_null_score_level() {
852 let q = json!({"type": "score", "instructions": "rate severity",
854 "criteria": ["low", null, "high"]});
855 let msg = check_question("severity", &q).unwrap_err().to_string();
856 assert!(msg.contains("score level 1 is null"), "got: {msg}");
857 }
858
859 #[test]
860 fn accepts_fully_described_score_levels() {
861 let q = json!({"type": "score", "instructions": "rate", "criteria": ["low", "high"]});
862 assert!(check_question("s", &q).is_ok());
863 }
864
865 #[test]
866 fn rejects_nested_choice_label() {
867 let q = json!({"type": "choice", "instructions": "route",
870 "criteria": [["billing"], "tech"]});
871 let msg = check_question("dept", &q).unwrap_err().to_string();
872 assert!(msg.contains("choice label 0 is a list"), "got: {msg}");
873
874 let q = json!({"type": "choice", "instructions": "route",
875 "criteria": ["billing", {"tech": "x"}]});
876 let msg = check_question("dept", &q).unwrap_err().to_string();
877 assert!(msg.contains("choice label 1 is a object"), "got: {msg}");
878 }
879
880 #[test]
881 fn accepts_scalar_choice_labels() {
882 let q = json!({"type": "choice", "instructions": "route",
884 "criteria": ["billing", 2, true]});
885 assert!(check_question("dept", &q).is_ok());
886 let q = json!({"type": "choice", "instructions": "route",
887 "criteria": {"billing": "money", "tech": "bugs"}});
888 assert!(check_question("dept", &q).is_ok());
889 }
890
891 #[test]
892 fn rejects_null_choice_label() {
893 let q = json!({"type": "choice", "instructions": "route",
896 "criteria": ["billing", null]});
897 let msg = check_question("dept", &q).unwrap_err().to_string();
898 assert!(msg.contains("choice label 1 is null"), "got: {msg}");
899 }
900
901 #[test]
902 fn rejects_duplicate_choice_labels() {
903 let q = json!({"type": "choice", "instructions": "route",
906 "criteria": ["billing", "tech", "billing"]});
907 let msg = check_question("dept", &q).unwrap_err().to_string();
908 assert!(
909 msg.contains("choice label 2") && msg.contains("repeats label 0"),
910 "got: {msg}"
911 );
912 }
913
914 #[test]
915 fn rejects_blank_question_id() {
916 let q = json!({"type": "noul", "instructions": "did it work"});
917 let msg = check_question(" ", &q).unwrap_err().to_string();
918 assert!(msg.contains("non-empty string"), "got: {msg}");
919 }
920
921 #[test]
922 fn rejects_null_and_empty_instructions() {
923 let q = json!({"type": "choice", "instructions": null, "criteria": ["a", "b"]});
926 assert!(check_question("q", &q)
927 .unwrap_err()
928 .to_string()
929 .contains("must not be None"));
930 let q = json!({"type": "choice", "instructions": " ", "criteria": ["a", "b"]});
931 assert!(check_question("q", &q)
932 .unwrap_err()
933 .to_string()
934 .contains("must not be empty"));
935 let q = json!({"type": "choice", "instructions": [], "criteria": ["a", "b"]});
936 assert!(check_question("q", &q)
937 .unwrap_err()
938 .to_string()
939 .contains("must not be empty"));
940 let q = json!({"type": "choice", "instructions": 3, "criteria": ["a", "b"]});
942 assert!(check_question("q", &q).is_ok());
943 }
944
945 #[test]
946 fn rejects_bad_option_order() {
947 for bad in [
949 json!([0, 2]),
950 json!([0]),
951 json!([0, 1, 1]),
952 json!(["0", "1"]),
953 json!(0),
954 ] {
955 let q = json!({"type": "choice", "instructions": "route",
956 "criteria": ["a", "b"], "option_order": bad});
957 let msg = check_question("dept", &q).unwrap_err().to_string();
958 assert!(
959 msg.contains("must be a permutation of range(2)"),
960 "got: {msg}"
961 );
962 }
963 }
964
965 #[test]
966 fn accepts_and_stores_option_order() {
967 let q = json!({"type": "choice", "instructions": "route",
968 "criteria": ["a", "b", "c"], "option_order": [2, 0, 1]});
969 assert!(check_question("dept", &q).is_ok());
970 assert_eq!(to_internal(&q).unwrap().option_order, Some(vec![2, 0, 1]));
971 let q = json!({"type": "noul", "instructions": "did it work", "option_order": [1, 0]});
973 assert!(check_question("ok", &q).is_ok());
974 }
975
976 #[test]
977 fn score_legend_renders_criterion_text() {
978 let q = json!({"type": "score", "instructions": "rate", "criteria": [1, 2, 3]});
981 let internal = to_internal(&q).unwrap();
982 let levels = internal.crit.as_array().unwrap();
983 let rendered: Vec<Value> = levels
984 .iter()
985 .map(|c| Value::String(render_criterion(c)))
986 .collect();
987 assert_eq!(rendered, vec![json!("1"), json!("2"), json!("3")]);
988 }
989}