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, mut opts: LoadOptions) -> Result<Agent> {
91 let mut repo = model_id_or_path.to_string();
92 if opts.subfolder.is_none() && !is_local_checkpoint_arg(model_id_or_path) {
93 if let Some((spec_repo, sub)) = crate::router::resolve_model_spec(model_id_or_path) {
94 repo = spec_repo;
95 opts.subfolder = sub;
96 }
97 }
98 let files = resolve_files(&repo, &opts)?;
99
100 let cfg: Value = serde_json::from_slice(&std::fs::read(&files.config)?)?;
101 let encoder_config: Value = serde_json::from_slice(&std::fs::read(&files.encoder_config)?)?;
102
103 let max_len = cfg.get("max_len").and_then(|v| v.as_u64()).unwrap_or(512) as usize;
104 let head_max_len = cfg
105 .get("head_max_len")
106 .and_then(|v| v.as_u64())
107 .unwrap_or(192) as usize;
108 let head_layers = cfg.get("head_layers").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
109 let n_act = cfg
110 .get("act_costs")
111 .and_then(|v| v.as_object())
112 .map(|m| m.len())
113 .unwrap_or(0)
114 + 1;
115
116 let temperature = load_temperature(&cfg);
117 let temperature_by_options = load_temperature_by_options(&cfg);
118
119 let device = resolve_device(opts.device.as_deref());
120 let model =
121 DecisionModel::load(&files.weights, &encoder_config, head_layers, n_act, device)?;
122 let tok = LayaTokenizer::from_file(&files.tokenizer)?;
123
124 Ok(Agent {
125 model,
126 tok,
127 max_len,
128 head_max_len,
129 temperature,
130 temperature_by_options,
131 })
132 }
133
134 pub fn system_one(&self, state: &State, questions: &Questions) -> Result<SystemOneResult> {
136 Ok(self
137 .predict_batch(std::slice::from_ref(state), questions, None)?
138 .pop()
139 .expect("one state → one result"))
140 }
141
142 pub fn predict(&self, state: &State, questions: &Questions) -> Result<SystemOneResult> {
144 self.system_one(state, questions)
145 }
146
147 pub fn predict_batch(
149 &self,
150 states: &[State],
151 questions: &Questions,
152 batch_size: Option<usize>,
153 ) -> Result<Vec<SystemOneResult>> {
154 if states.is_empty() {
155 return Ok(Vec::new());
156 }
157 let ids: Vec<String> = questions.keys().cloned().collect();
158 if ids.is_empty() {
159 return Ok(states
160 .iter()
161 .map(|_| SystemOneResult {
162 model: "laya-rl-agent".to_string(),
163 answers: IndexMap::new(),
164 input_tokens: 0,
165 output_tokens: 0,
166 routing: None,
167 })
168 .collect());
169 }
170
171 let mut internal: Vec<(String, InternalQ)> = Vec::with_capacity(ids.len());
173 for qid in &ids {
174 let qdef = &questions[qid];
175 check_question(qid, qdef)?;
176 internal.push((qid.clone(), to_internal(qdef)?));
177 }
178
179 let chunk = batch_size.filter(|&b| b > 0).unwrap_or(states.len());
180 let mut results = Vec::with_capacity(states.len());
181
182 for part in states.chunks(chunk) {
183 let mut per_state_items: Vec<Vec<Item>> = Vec::with_capacity(part.len());
184 for st in part {
185 per_state_items.push(self.encode_state(st, &internal)?);
186 }
187 let batch = collate_items(&per_state_items, self.tok_pad())
188 .ok_or_else(|| LayaError::Model("empty batch".to_string()))?;
189 let (logits, act) = self.model.forward(&batch)?;
190
191 let mut row = 0usize;
192 for items in &per_state_items {
193 let nrows = items.len();
194 let n_tokens: usize = (row..row + nrows)
195 .map(|r| {
196 batch.attention_mask[r]
197 .iter()
198 .map(|&x| x as usize)
199 .sum::<usize>()
200 })
201 .sum();
202 let answers = self.decode(&logits, &act, items, &internal, row);
203 results.push(SystemOneResult {
204 model: "laya-rl-agent".to_string(),
205 answers,
206 input_tokens: n_tokens,
207 output_tokens: 0,
208 routing: None,
209 });
210 row += nrows;
211 }
212 }
213 Ok(results)
214 }
215
216 fn tok_pad(&self) -> u32 {
217 use crate::common::Tokenizer;
218 self.tok.pad_id()
219 }
220
221 pub fn embed(
228 &self,
229 texts: &[String],
230 max_length: usize,
231 batch_size: usize,
232 ) -> Result<Vec<Vec<f32>>> {
233 if texts.is_empty() {
234 return Ok(Vec::new());
235 }
236 let pad = self.tok_pad();
237 let mut out = Vec::with_capacity(texts.len());
238 for chunk in texts.chunks(batch_size.max(1)) {
239 let rows: Vec<Vec<u32>> = chunk
240 .iter()
241 .map(|t| self.tok.encode_with_special(t, max_length))
242 .collect();
243 let l = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
244 let mut ids = Vec::with_capacity(rows.len());
245 let mut att = Vec::with_capacity(rows.len());
246 for r in &rows {
247 let mut row = r.clone();
248 let mut a = vec![1u32; row.len()];
249 row.resize(l, pad);
250 a.resize(l, 0);
251 ids.push(row);
252 att.push(a);
253 }
254 out.extend(self.model.embed(&ids, &att)?);
255 }
256 Ok(out)
257 }
258
259 fn encode_state(&self, state: &State, internal: &[(String, InternalQ)]) -> Result<Vec<Item>> {
260 let truncate_left = state.is_array();
261 let mut items = Vec::with_capacity(internal.len());
262 for (qid, q) in internal {
263 let (seq, markers) = build_sequence(
264 &self.tok,
265 state,
266 q,
267 self.max_len,
268 self.head_max_len,
269 q.option_order.as_deref(),
270 truncate_left,
271 )?;
272 if markers.len() != render_options(q)?.len() {
273 return Err(LayaError::InvalidQuestion(format!(
274 "question {:?} options exceed head_max_len={}",
275 qid, self.head_max_len
276 )));
277 }
278 items.push(Item {
279 ids: seq,
280 markers,
281 qtype: q.t.index(),
282 });
283 }
284 Ok(items)
285 }
286
287 fn decode(
288 &self,
289 logits: &[Vec<f32>],
290 act: &[Vec<f32>],
291 items: &[Item],
292 internal: &[(String, InternalQ)],
293 offset: usize,
294 ) -> IndexMap<String, Value> {
295 let mut answers = IndexMap::new();
296 for (j, (qid, q)) in internal.iter().enumerate() {
297 let r = offset + j;
298 let k = items[j].markers.len();
299 let qt = q.t.index();
300 let t_scale = self
301 .temperature_by_options
302 .get(&temp_bucket(qt, k))
303 .copied()
304 .unwrap_or(self.temperature[qt]);
305
306 let z: Vec<f64> = logits[r][..k].iter().map(|&v| v as f64 / t_scale).collect();
307 let p = softmax(&z);
308 let p = unpermute_probs(&p, q.option_order.as_deref());
311 let conf = round4(confidence_from_probs(&p, k));
312 let ans_conf = round4(answer_confidence(&p, k));
317
318 let act_p = softmax(&act[r].iter().map(|&v| v as f64).collect::<Vec<_>>());
319 let act_probability = round4(act_p.first().copied().unwrap_or(0.0));
320 let action = json!({ "act_probability": act_probability });
321
322 let answer = match q.t {
323 QType::Choice => {
324 let keys: Vec<String> = q
325 .crit
326 .as_object()
327 .map(|m| m.keys().cloned().collect())
328 .unwrap_or_default();
329 let best = argmax(&p);
330 let probs: Map<String, Value> = keys
331 .iter()
332 .zip(p.iter())
333 .map(|(kk, &v)| (kk.clone(), json!(round4(v))))
334 .collect();
335 json!({
336 "type": "choice",
337 "choice": keys.get(best).cloned().unwrap_or_default(),
338 "probabilities": Value::Object(probs),
339 "confidence": conf,
340 "answer_confidence": ans_conf,
341 "action": action,
342 })
343 }
344 QType::Score => {
345 let exp: f64 = p.iter().enumerate().map(|(i, &v)| i as f64 * v).sum();
346 let legend: Map<String, Value> = q
352 .crit
353 .as_array()
354 .map(|a| {
355 a.iter()
356 .enumerate()
357 .map(|(i, c)| (i.to_string(), Value::String(render_criterion(c))))
358 .collect()
359 })
360 .unwrap_or_default();
361 let probs: Map<String, Value> = p
362 .iter()
363 .enumerate()
364 .map(|(i, &v)| (i.to_string(), json!(round4(v))))
365 .collect();
366 json!({
367 "type": "score",
368 "score": round4(exp),
369 "legend": Value::Object(legend),
370 "probabilities": Value::Object(probs),
371 "confidence": conf,
372 "answer_confidence": ans_conf,
373 "action": action,
374 })
375 }
376 QType::Noul => {
377 let p_true = p.get(1).copied().unwrap_or(0.0);
378 json!({
379 "type": "noul",
380 "noul": round4(p_true),
381 "confidence": round4(p_true.max(1.0 - p_true)),
382 "answer_confidence": ans_conf,
384 "action": action,
385 })
386 }
387 };
388 answers.insert(qid.clone(), answer);
389 }
390 answers
391 }
392}
393
394fn argmax(p: &[f64]) -> usize {
395 let mut best = 0usize;
396 for i in 1..p.len() {
397 if p[i] > p[best] {
398 best = i;
399 }
400 }
401 best
402}
403
404fn load_temperature(cfg: &Value) -> [f64; 3] {
405 let arr = cfg.get("temperature").and_then(|v| v.as_array());
406 let mut out = [1.0; 3];
407 if let Some(a) = arr {
408 for (i, slot) in out.iter_mut().enumerate() {
409 if let Some(v) = a.get(i) {
410 *slot = clamp_temperature(v);
411 }
412 }
413 }
414 out
415}
416
417fn load_temperature_by_options(cfg: &Value) -> std::collections::HashMap<String, f64> {
418 cfg.get("temperature_by_options")
419 .and_then(|v| v.as_object())
420 .map(|m| {
421 m.iter()
422 .map(|(k, v)| (k.clone(), clamp_temperature(v)))
423 .collect()
424 })
425 .unwrap_or_default()
426}
427
428fn resolve_device(device: Option<&str>) -> Device {
429 match device.map(|d| d.trim().to_lowercase()).as_deref() {
430 Some("metal") => {
431 #[cfg(feature = "metal")]
432 {
433 match Device::new_metal(0) {
434 Ok(d) => d,
435 Err(e) => {
436 eprintln!(
437 "laya: Metal requested but unavailable ({e}); falling back to CPU."
438 );
439 Device::Cpu
440 }
441 }
442 }
443 #[cfg(not(feature = "metal"))]
444 {
445 eprintln!(
446 "laya: device=\"metal\" requested but the crate was built without the `metal` \
447 feature; using CPU. Rebuild with --features metal."
448 );
449 Device::Cpu
450 }
451 }
452 _ => Device::Cpu,
454 }
455}
456
457fn check_question(qid: &str, qdef: &Value) -> Result<()> {
459 if qid.trim().is_empty() {
463 return Err(LayaError::InvalidQuestion(format!(
464 "question id must be a non-empty string, got {:?}",
465 qid
466 )));
467 }
468 let obj = match qdef {
469 Value::Object(m) => m,
470 other => {
471 let kind = match other {
472 Value::Array(_) => "list",
473 Value::Null => "NoneType",
474 Value::String(_) => "str",
475 Value::Bool(_) => "bool",
476 Value::Number(_) => "number",
477 Value::Object(_) => unreachable!(),
478 };
479 return Err(LayaError::InvalidQuestion(format!(
480 "question {:?}: definition must be a dict, got {}",
481 qid, kind
482 )));
483 }
484 };
485 let t = obj.get("type").and_then(|v| v.as_str());
486 let t = match t.and_then(QType::from_name) {
487 Some(t) => t,
488 None => {
489 return Err(LayaError::InvalidQuestion(format!(
490 "question {:?}: unknown type {:?}; use one of [\"choice\", \"noul\", \"score\"]",
491 qid,
492 obj.get("type").unwrap_or(&Value::Null)
493 )))
494 }
495 };
496 if !obj.contains_key("instructions") {
497 return Err(LayaError::InvalidQuestion(format!(
498 "question {:?}: no 'instructions'; add the text the model should answer",
499 qid
500 )));
501 }
502 match obj.get("instructions") {
506 Some(Value::Null) => {
507 return Err(LayaError::InvalidQuestion(format!(
508 "question {:?}: 'instructions' must not be None; add the text the model should answer",
509 qid
510 )));
511 }
512 Some(Value::String(s)) if s.trim().is_empty() => {
513 return Err(LayaError::InvalidQuestion(format!(
514 "question {:?}: 'instructions' must not be empty; add the text the model should answer",
515 qid
516 )));
517 }
518 Some(Value::Array(a)) if a.is_empty() => {
519 return Err(LayaError::InvalidQuestion(format!(
520 "question {:?}: 'instructions' must not be empty; add the text the model should answer",
521 qid
522 )));
523 }
524 Some(Value::Object(m)) if m.is_empty() => {
525 return Err(LayaError::InvalidQuestion(format!(
526 "question {:?}: 'instructions' must not be empty; add the text the model should answer",
527 qid
528 )));
529 }
530 _ => {}
531 }
532 let crit = obj.get("criteria");
533 match t {
534 QType::Choice => {
535 let ok = matches!(crit, Some(Value::Object(_)) | Some(Value::Array(_)));
536 if !ok {
537 return Err(LayaError::InvalidQuestion(format!(
538 "question {:?}: a choice question takes 'criteria' as a dict of label -> description, or a list of labels",
539 qid
540 )));
541 }
542 let empty = match crit {
543 Some(Value::Object(m)) => m.is_empty(),
544 Some(Value::Array(a)) => a.is_empty(),
545 _ => true,
546 };
547 if empty {
548 return Err(LayaError::InvalidQuestion(format!(
549 "question {:?}: a choice question needs at least one criterion",
550 qid
551 )));
552 }
553 if let Some(Value::Array(a)) = crit {
559 let mut seen: std::collections::HashMap<String, usize> =
566 std::collections::HashMap::new();
567 for (i, label) in a.iter().enumerate() {
568 let kind = match label {
569 Value::Array(_) => "list",
570 Value::Object(_) => "object",
571 Value::Null => {
576 return Err(LayaError::InvalidQuestion(format!(
577 "question {:?}: choice label {} is null; a label is rendered as \
578 option text and used as the answer key, so it must be a string, \
579 number or bool",
580 qid, i
581 )));
582 }
583 _ => {
584 let key = label
585 .as_str()
586 .map(|s| s.to_string())
587 .unwrap_or_else(|| crate::common::py_json(label));
588 if let Some(&first) = seen.get(&key) {
589 return Err(LayaError::InvalidQuestion(format!(
590 "question {:?}: choice label {} ({}) repeats label {}; the \
591 labels are the answer keys, so every option needs its own",
592 qid,
593 i,
594 crate::common::py_json(label),
595 first
596 )));
597 }
598 seen.insert(key, i);
599 continue;
600 }
601 };
602 return Err(LayaError::InvalidQuestion(format!(
603 "question {:?}: choice label {} is a {}; a label is rendered as option text \
604 and used as the answer key, so it must be a scalar (a string, number or \
605 bool), got {}",
606 qid,
607 i,
608 kind,
609 crate::common::py_json(label)
610 )));
611 }
612 }
613 }
614 QType::Score => {
615 let arr = match crit {
616 Some(Value::Array(a)) => a,
617 _ => {
618 return Err(LayaError::InvalidQuestion(format!(
619 "question {:?}: a score question takes 'criteria' as a list of level descriptions, index 0 first",
620 qid
621 )))
622 }
623 };
624 if arr.is_empty() {
625 return Err(LayaError::InvalidQuestion(format!(
626 "question {:?}: a score question needs at least one level",
627 qid
628 )));
629 }
630 if let Some(idx) = arr.iter().position(|v| v.is_null()) {
633 return Err(LayaError::InvalidQuestion(format!(
634 "question {:?}: score level {} is null; give every level a description, index 0 first",
635 qid, idx
636 )));
637 }
638 }
639 QType::Noul => {
640 if let Some(c) = crit {
641 if !c.is_null() && !c.is_object() {
642 return Err(LayaError::InvalidQuestion(format!(
643 "question {:?}: a noul question takes 'criteria' as a dict with optional 'true'/'false' descriptions, or omits it",
644 qid
645 )));
646 }
647 if let Some(map) = c.as_object() {
651 let mut keys: Vec<String> = map.keys().map(|k| k.to_lowercase()).collect();
652 if keys.iter().any(|k| k != "true" && k != "false") {
653 keys.sort();
654 let shown: Vec<Value> = keys.into_iter().map(Value::String).collect();
655 return Err(LayaError::InvalidQuestion(format!(
656 "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.",
657 qid,
658 Value::Array(shown)
659 )));
660 }
661 }
662 }
663 }
664 }
665 if let Some(order) = obj.get("option_order") {
666 let n = option_count(t, crit);
672 let ok = match order {
673 Value::Array(a) => {
674 let mut idxs: Vec<i64> = a
675 .iter()
676 .filter_map(|v| match v {
677 Value::Number(num) => num.as_i64(),
678 _ => None,
679 })
680 .collect();
681 idxs.sort_unstable();
682 a.len() == n && idxs == (0..n as i64).collect::<Vec<_>>()
683 }
684 _ => false,
685 };
686 if !ok {
687 return Err(LayaError::InvalidQuestion(format!(
688 "question {:?}: 'option_order' must be a permutation of range({}) -- one slot per \
689 option, each option once -- got {}",
690 qid,
691 n,
692 crate::common::py_json(order)
693 )));
694 }
695 }
696 if obj.contains_key("labels") {
697 if t != QType::Noul {
698 return Err(LayaError::InvalidQuestion(format!(
699 "question {:?}: 'labels' is only supported for noul questions",
700 qid
701 )));
702 }
703 crate::common::resolve_noul_labels(obj.get("labels"))
704 .map_err(|e| LayaError::InvalidQuestion(format!("question {:?}: {}", qid, e)))?;
705 }
706 Ok(())
707}
708
709fn option_count(t: QType, crit: Option<&Value>) -> usize {
713 match t {
714 QType::Noul => 2,
715 _ => match crit {
716 Some(Value::Object(m)) => m.len(),
717 Some(Value::Array(a)) => a.len(),
718 _ => 0,
719 },
720 }
721}
722
723fn to_internal(qdef: &Value) -> Result<InternalQ> {
725 let obj = qdef.as_object().expect("validated as object");
726 let t = QType::from_name(obj.get("type").and_then(|v| v.as_str()).unwrap_or("")).unwrap();
727 let mut crit = obj.get("criteria").cloned().unwrap_or(Value::Null);
728 match t {
729 QType::Choice => {
730 if let Value::Array(a) = &crit {
731 let mut m = Map::new();
732 for c in a {
733 let key = c
734 .as_str()
735 .map(|s| s.to_string())
736 .unwrap_or_else(|| crate::common::py_json(c));
737 m.insert(key, Value::Null);
738 }
739 crit = Value::Object(m);
740 }
741 }
742 QType::Noul => {
743 if let Value::Object(m) = &crit {
744 let lowered: Map<String, Value> = m
745 .iter()
746 .map(|(k, v)| (k.to_lowercase(), v.clone()))
747 .collect();
748 crit = Value::Object(lowered);
749 }
750 }
751 QType::Score => {}
752 }
753 let ins = match obj.get("instructions") {
754 Some(Value::String(s)) => s.clone(),
755 Some(other) => crate::common::py_json(other),
756 None => String::new(),
757 };
758 let labels = obj.get("labels").cloned();
759 let option_order = obj.get("option_order").and_then(|v| v.as_array()).map(|a| {
761 a.iter()
762 .filter_map(|i| i.as_u64())
763 .map(|i| i as usize)
764 .collect()
765 });
766 Ok(InternalQ {
767 t,
768 ins,
769 crit,
770 labels,
771 option_order,
772 })
773}
774
775fn is_local_checkpoint_arg(model_id_or_path: &str) -> bool {
782 if model_id_or_path.is_empty() {
783 return false;
784 }
785 if model_id_or_path.starts_with('.') || model_id_or_path.starts_with('~') {
786 return true;
787 }
788 if model_id_or_path.contains('/')
789 || model_id_or_path.contains('\\')
790 || model_id_or_path.contains(std::path::MAIN_SEPARATOR)
791 {
792 return true;
793 }
794 let dir = Path::new(model_id_or_path);
795 dir.is_dir() && dir.join("rl_agent_config.json").is_file()
796}
797
798fn resolve_files(model_id_or_path: &str, opts: &LoadOptions) -> Result<CheckpointFiles> {
799 let base = Path::new(model_id_or_path);
800 if base.exists() {
801 let dir = match &opts.subfolder {
802 Some(sub) => base.join(sub),
803 None => base.to_path_buf(),
804 };
805 let config = require(dir.join("rl_agent_config.json"))?;
806 let weights = require(dir.join("model.safetensors"))?;
807 let encoder_config = if dir.join("encoder/config.json").exists() {
808 dir.join("encoder/config.json")
809 } else {
810 require(dir.join("config.json"))?
811 };
812 let tokenizer = if dir.join("tokenizer/tokenizer.json").exists() {
813 dir.join("tokenizer/tokenizer.json")
814 } else {
815 require(dir.join("tokenizer.json"))?
816 };
817 return Ok(CheckpointFiles {
818 config,
819 weights,
820 encoder_config,
821 tokenizer,
822 });
823 }
824 if model_id_or_path.starts_with('/')
825 || model_id_or_path.starts_with("./")
826 || model_id_or_path.starts_with("../")
827 {
828 return Err(LayaError::NotFound(format!(
829 "Local model path not found: {:?}.",
830 model_id_or_path
831 )));
832 }
833 download_files(model_id_or_path, opts)
834}
835
836fn require(p: PathBuf) -> Result<PathBuf> {
837 if p.exists() {
838 Ok(p)
839 } else {
840 Err(LayaError::NotFound(format!(
841 "missing checkpoint file: {}",
842 p.display()
843 )))
844 }
845}
846
847fn download_files(repo: &str, opts: &LoadOptions) -> Result<CheckpointFiles> {
848 use hf_hub::api::sync::ApiBuilder;
849 let token = opts
852 .token
853 .clone()
854 .or_else(|| std::env::var("HF_TOKEN").ok())
855 .filter(|t| !t.is_empty());
856 let api = ApiBuilder::new()
857 .with_token(token)
858 .build()
859 .map_err(|e| LayaError::Download(e.to_string()))?;
860 let repo_api = api.model(repo.to_string());
861 let prefix = match &opts.subfolder {
862 Some(sub) => format!("{}/", sub),
863 None => String::new(),
864 };
865 let get = |name: &str| -> Result<PathBuf> {
866 repo_api
867 .get(&format!("{}{}", prefix, name))
868 .map_err(|e| LayaError::Download(format!("{}{}: {}", prefix, name, e)))
869 };
870 let config = get("rl_agent_config.json")?;
871 let weights = get("model.safetensors")?;
872 let encoder_config = get("encoder/config.json")?;
873 let tokenizer = get("tokenizer/tokenizer.json").or_else(|_| get("tokenizer.json"))?;
874 Ok(CheckpointFiles {
875 config,
876 weights,
877 encoder_config,
878 tokenizer,
879 })
880}
881
882#[cfg(test)]
883mod tests {
884 use super::*;
885
886 #[test]
887 fn local_checkpoint_arg_detects_paths_but_not_bare_names() {
888 assert!(!is_local_checkpoint_arg("typed-decisions"));
890 assert!(!is_local_checkpoint_arg("laya"));
891 assert!(!is_local_checkpoint_arg(""));
892 assert!(is_local_checkpoint_arg("./laya"));
894 assert!(is_local_checkpoint_arg("~/laya"));
895 assert!(is_local_checkpoint_arg("org/repo"));
896 assert!(is_local_checkpoint_arg("../ckpt"));
897 }
898
899 #[test]
900 fn rejects_null_score_level() {
901 let q = json!({"type": "score", "instructions": "rate severity",
903 "criteria": ["low", null, "high"]});
904 let msg = check_question("severity", &q).unwrap_err().to_string();
905 assert!(msg.contains("score level 1 is null"), "got: {msg}");
906 }
907
908 #[test]
909 fn accepts_fully_described_score_levels() {
910 let q = json!({"type": "score", "instructions": "rate", "criteria": ["low", "high"]});
911 assert!(check_question("s", &q).is_ok());
912 }
913
914 #[test]
915 fn rejects_nested_choice_label() {
916 let q = json!({"type": "choice", "instructions": "route",
919 "criteria": [["billing"], "tech"]});
920 let msg = check_question("dept", &q).unwrap_err().to_string();
921 assert!(msg.contains("choice label 0 is a list"), "got: {msg}");
922
923 let q = json!({"type": "choice", "instructions": "route",
924 "criteria": ["billing", {"tech": "x"}]});
925 let msg = check_question("dept", &q).unwrap_err().to_string();
926 assert!(msg.contains("choice label 1 is a object"), "got: {msg}");
927 }
928
929 #[test]
930 fn accepts_scalar_choice_labels() {
931 let q = json!({"type": "choice", "instructions": "route",
933 "criteria": ["billing", 2, true]});
934 assert!(check_question("dept", &q).is_ok());
935 let q = json!({"type": "choice", "instructions": "route",
936 "criteria": {"billing": "money", "tech": "bugs"}});
937 assert!(check_question("dept", &q).is_ok());
938 }
939
940 #[test]
941 fn rejects_null_choice_label() {
942 let q = json!({"type": "choice", "instructions": "route",
945 "criteria": ["billing", null]});
946 let msg = check_question("dept", &q).unwrap_err().to_string();
947 assert!(msg.contains("choice label 1 is null"), "got: {msg}");
948 }
949
950 #[test]
951 fn rejects_duplicate_choice_labels() {
952 let q = json!({"type": "choice", "instructions": "route",
955 "criteria": ["billing", "tech", "billing"]});
956 let msg = check_question("dept", &q).unwrap_err().to_string();
957 assert!(
958 msg.contains("choice label 2") && msg.contains("repeats label 0"),
959 "got: {msg}"
960 );
961 }
962
963 #[test]
964 fn rejects_blank_question_id() {
965 let q = json!({"type": "noul", "instructions": "did it work"});
966 let msg = check_question(" ", &q).unwrap_err().to_string();
967 assert!(msg.contains("non-empty string"), "got: {msg}");
968 }
969
970 #[test]
971 fn rejects_null_and_empty_instructions() {
972 let q = json!({"type": "choice", "instructions": null, "criteria": ["a", "b"]});
975 assert!(check_question("q", &q)
976 .unwrap_err()
977 .to_string()
978 .contains("must not be None"));
979 let q = json!({"type": "choice", "instructions": " ", "criteria": ["a", "b"]});
980 assert!(check_question("q", &q)
981 .unwrap_err()
982 .to_string()
983 .contains("must not be empty"));
984 let q = json!({"type": "choice", "instructions": [], "criteria": ["a", "b"]});
985 assert!(check_question("q", &q)
986 .unwrap_err()
987 .to_string()
988 .contains("must not be empty"));
989 let q = json!({"type": "choice", "instructions": 3, "criteria": ["a", "b"]});
991 assert!(check_question("q", &q).is_ok());
992 }
993
994 #[test]
995 fn rejects_bad_option_order() {
996 for bad in [
998 json!([0, 2]),
999 json!([0]),
1000 json!([0, 1, 1]),
1001 json!(["0", "1"]),
1002 json!(0),
1003 ] {
1004 let q = json!({"type": "choice", "instructions": "route",
1005 "criteria": ["a", "b"], "option_order": bad});
1006 let msg = check_question("dept", &q).unwrap_err().to_string();
1007 assert!(
1008 msg.contains("must be a permutation of range(2)"),
1009 "got: {msg}"
1010 );
1011 }
1012 }
1013
1014 #[test]
1015 fn accepts_and_stores_option_order() {
1016 let q = json!({"type": "choice", "instructions": "route",
1017 "criteria": ["a", "b", "c"], "option_order": [2, 0, 1]});
1018 assert!(check_question("dept", &q).is_ok());
1019 assert_eq!(to_internal(&q).unwrap().option_order, Some(vec![2, 0, 1]));
1020 let q = json!({"type": "noul", "instructions": "did it work", "option_order": [1, 0]});
1022 assert!(check_question("ok", &q).is_ok());
1023 }
1024
1025 #[test]
1026 fn score_legend_renders_criterion_text() {
1027 let q = json!({"type": "score", "instructions": "rate", "criteria": [1, 2, 3]});
1030 let internal = to_internal(&q).unwrap();
1031 let levels = internal.crit.as_array().unwrap();
1032 let rendered: Vec<Value> = levels
1033 .iter()
1034 .map(|c| Value::String(render_criterion(c)))
1035 .collect();
1036 assert_eq!(rendered, vec![json!("1"), json!("2"), json!("3")]);
1037 }
1038}