Skip to main content

laya/
agent.rs

1//! High-level inference runtime. Ported from `laya/agent.py` (load, validate/normalize
2//! questions, tokenize, forward, decode typed answers) — minus the TileLang fast path.
3
4use 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/// Options for [`Agent::load`].
21#[derive(Debug, Clone, Default)]
22pub struct LoadOptions {
23    /// Compute device: `None`/`"cpu"` for CPU (the only backend built by default).
24    pub device: Option<String>,
25    /// Hugging Face token (falls back to `HF_TOKEN`).
26    pub token: Option<String>,
27    /// Subfolder within a bundled repo (e.g. `"multilingual"`).
28    pub subfolder: Option<String>,
29}
30
31/// The result of a single-state evaluation (Jev-shaped).
32#[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    /// Set by [`crate::Router::predict`]; the routing decision as JSON.
39    pub routing: Option<Value>,
40}
41
42impl SystemOneResult {
43    /// Serialise to the Jev wire shape: `{model, answers, usage, routing?}`.
44    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
71/// System-1 decision model runtime.
72pub 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    /// Load a Laya checkpoint from a local directory, a Hugging Face repo id, or a registry
83    /// checkpoint name or alias (the same ones `Router` resolves: `"typed-decisions"`, `"ml"`, …).
84    ///
85    /// A bare name or alias resolves to the same `(repo, subfolder)` the `Router` would pick,
86    /// instead of being handed to the Hub as a repo id. A caller who spelled out a `subfolder` is
87    /// loading that subfolder of whatever repo they named, so the name is only resolved when it is
88    /// the whole argument, and only when it does not already look like a local path or name a
89    /// directory that holds a checkpoint (upstream `load` name resolution + `_is_local_checkpoint_arg`).
90    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    /// Evaluate typed questions over one state in a single forward pass.
135    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    /// Alias for [`Agent::system_one`].
143    pub fn predict(&self, state: &State, questions: &Questions) -> Result<SystemOneResult> {
144        self.system_one(state, questions)
145    }
146
147    /// Evaluate the same questions over many states, sharing forward passes.
148    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        // Validate + normalize each question once (state-independent).
172        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    /// Mean-pooled encoder embeddings for a list of texts (`[n, hidden_size]`).
222    ///
223    /// Tokenizes with special tokens, truncates to `max_length`, runs the checkpoint encoder in
224    /// batches of `batch_size`, and mean-pools over non-padding tokens. Backs
225    /// [`crate::shortlist::embed_fn_from_agent`]; a dedicated bi-encoder will usually shortlist
226    /// better, but this needs only the loaded checkpoint.
227    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            // The row comes back in slot order; everything below indexes by option, so invert the
309            // permutation first when the question chose a display order (upstream unpermute_probs).
310            let p = unpermute_probs(&p, q.option_order.as_deref());
311            let conf = round4(confidence_from_probs(&p, k));
312            // `confidence` means one thing for `noul` (max(p)) and another for `choice`/`score`
313            // (normalized entropy), and only the first is the quantity temperature scaling fits and
314            // ECE measures. Report both: `answer_confidence` is the calibrated one on every type, so
315            // a caller can gate across question types on a single number.
316            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                    // A legend maps an index to the text of a level. `render_criterion` rather than
347                    // the raw value: a dict or list level comes back as the same JSON text the model
348                    // was shown, and a numeric scale passed as `[1, 2, 3]` comes back as strings, so
349                    // the response's JSON types no longer depend on what the caller happened to pass
350                    // — `probabilities` stringifies its keys right below (upstream render_criterion).
351                    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                        // identical here: over two options max(p_true, 1 - p_true) is max(p)
383                        "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        // "cuda" and other accelerators are follow-ups; everything else runs on CPU.
453        _ => Device::Cpu,
454    }
455}
456
457/// Validate a question definition, matching `Agent._check_question`.
458fn check_question(qid: &str, qdef: &Value) -> Result<()> {
459    // A blank id cannot name an answer back to the caller (upstream: reject an empty question id).
460    // Rust JSON object keys are always strings, so the None / non-str cases upstream also guards do
461    // not arise here.
462    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    // A present-but-unusable `instructions` is the same silent-shape class as a null/duplicate
503    // label: `null` would be serialized as the text "null", and an empty string/list/dict gives
504    // the model nothing to answer. A number or bool is left alone (upstream instructions validation).
505    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            // A label in the list form is used as the answer key and rendered as option text, so a
554            // nested list or object label has no meaning here. In the dict form keys are always
555            // strings, so only the list form can carry one. Reject it, naming the question and the
556            // label, rather than letting it be JSON-stringified into a key (upstream #425). The dict
557            // form (JSON object) cannot hit this: its keys are strings by construction.
558            if let Some(Value::Array(a)) = crit {
559                // Each list label becomes an answer key exactly as `to_internal` builds it: a string
560                // stays itself, anything else is rendered with `py_json`. Two labels that land on
561                // one key made the model score fewer options than the caller wrote and the response
562                // carry fewer probabilities, without a word — the same silent-shape class as the
563                // type and null checks (upstream #425 duplicate-label fix). Detected on the rendered
564                // key, which is what Rust actually uses to distinguish options.
565                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                        // A null label is neither valid option text nor a usable answer key: it
572                        // would render as the text "null" while its key is the JSON string "null",
573                        // so a client cannot tell it from the string "null". `""` is left alone,
574                        // because unlike a null it round-trips (upstream: reject a null label).
575                        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            // A null level would silently drop that description; reject it and name the index so
631            // every level gets described, index 0 first (upstream: reject a null score level).
632            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                // `render_options` reads only `false`/`true` out by name, so a dict keyed any other
648                // way is not a noul description at all — it used to be silently dropped and replaced
649                // with the defaults (#156). A noul is a boolean question, so those are its only keys.
650                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        // Slot s shows option `order[s]`. Anything other than a permutation of the option indices
667        // would either drop an option or show one twice, so reject it here rather than let it reach
668        // the encoder (upstream option_order validation). The permutation is checked on the integer
669        // entries only: a non-integer entry is dropped, so the surviving set falls short of range(n)
670        // and the whole order is rejected — the same as Python's filtered `sorted(...)` compare.
671        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
709/// How many options a validated question definition renders to. Mirrors `render_options`: a choice
710/// has one option per criterion, a score one per level, and a noul is always the pair [false, true].
711/// Used to validate `option_order` (upstream `_option_count`).
712fn 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
723/// Normalize a validated question into the internal form.
724fn 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    // Validated as a permutation of range(n) above, so every entry is a non-negative integer.
760    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
775/// Resolve the four checkpoint files from a local dir or a Hugging Face repo id.
776/// True when `model_id_or_path` should be treated as a local path, not a registry name. A bare word
777/// with no path separator reads as a name/alias; only treat it as a path when it looks like one
778/// (contains a separator, or starts with `.`/`~`) or when the named directory actually holds a Laya
779/// checkpoint (`rl_agent_config.json`). So `load("laya")` resolves the alias while `load("./laya")`
780/// and a real checkpoint directory still load from disk (upstream `_is_local_checkpoint_arg`).
781fn 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    // An empty token (explicit or from an empty `HF_TOKEN`) is treated as no token, so we never
850    // send an empty `Bearer` header to the hub (parity with upstream `token or HF_TOKEN or None`).
851    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        // Bare names/aliases are not local paths: they resolve through the registry.
889        assert!(!is_local_checkpoint_arg("typed-decisions"));
890        assert!(!is_local_checkpoint_arg("laya"));
891        assert!(!is_local_checkpoint_arg(""));
892        // Path-shaped arguments are local.
893        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        // A null level would silently drop that description; it must be rejected, naming the index.
902        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        // A list/object label in the list form is used as the answer key: reject it as a named
917        // caller error, naming the question and the label index (upstream #425).
918        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        // Strings, numbers and bools are all valid answer keys.
932        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        // A null label renders as the text "null" while its answer key is the JSON string "null",
943        // so a client cannot tell it from the string "null"; reject it (upstream signoff/label fix).
944        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        // Two labels that land on one answer key make the model score fewer options than were
953        // written and the response carry fewer probabilities, silently (upstream #425 duplicate fix).
954        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        // `null` would serialize as the text "null"; an empty string/list/dict gives the model
973        // nothing to answer (upstream instructions validation).
974        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        // A number is left alone, matching Python's `isinstance(ins, (str, dict, list, int, float))`.
990        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        // Not a permutation of range(2): out of range, wrong length, or a non-integer entry.
997        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        // A noul always has two options, so [1, 0] is the only non-canonical order.
1021        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        // A numeric or structured level comes back as the JSON text the model was shown, not the
1028        // raw JSON type (upstream render_criterion legend fix). Verified through the internal form.
1029        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}