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 or a Hugging Face repo id.
83    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    /// Evaluate typed questions over one state in a single forward pass.
121    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    /// Alias for [`Agent::system_one`].
129    pub fn predict(&self, state: &State, questions: &Questions) -> Result<SystemOneResult> {
130        self.system_one(state, questions)
131    }
132
133    /// Evaluate the same questions over many states, sharing forward passes.
134    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        // Validate + normalize each question once (state-independent).
158        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    /// Mean-pooled encoder embeddings for a list of texts (`[n, hidden_size]`).
208    ///
209    /// Tokenizes with special tokens, truncates to `max_length`, runs the checkpoint encoder in
210    /// batches of `batch_size`, and mean-pools over non-padding tokens. Backs
211    /// [`crate::shortlist::embed_fn_from_agent`]; a dedicated bi-encoder will usually shortlist
212    /// better, but this needs only the loaded checkpoint.
213    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            // The row comes back in slot order; everything below indexes by option, so invert the
295            // permutation first when the question chose a display order (upstream unpermute_probs).
296            let p = unpermute_probs(&p, q.option_order.as_deref());
297            let conf = round4(confidence_from_probs(&p, k));
298            // `confidence` means one thing for `noul` (max(p)) and another for `choice`/`score`
299            // (normalized entropy), and only the first is the quantity temperature scaling fits and
300            // ECE measures. Report both: `answer_confidence` is the calibrated one on every type, so
301            // a caller can gate across question types on a single number.
302            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                    // A legend maps an index to the text of a level. `render_criterion` rather than
333                    // the raw value: a dict or list level comes back as the same JSON text the model
334                    // was shown, and a numeric scale passed as `[1, 2, 3]` comes back as strings, so
335                    // the response's JSON types no longer depend on what the caller happened to pass
336                    // — `probabilities` stringifies its keys right below (upstream render_criterion).
337                    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                        // identical here: over two options max(p_true, 1 - p_true) is max(p)
369                        "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        // "cuda" and other accelerators are follow-ups; everything else runs on CPU.
439        _ => Device::Cpu,
440    }
441}
442
443/// Validate a question definition, matching `Agent._check_question`.
444fn check_question(qid: &str, qdef: &Value) -> Result<()> {
445    // A blank id cannot name an answer back to the caller (upstream: reject an empty question id).
446    // Rust JSON object keys are always strings, so the None / non-str cases upstream also guards do
447    // not arise here.
448    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    // A present-but-unusable `instructions` is the same silent-shape class as a null/duplicate
489    // label: `null` would be serialized as the text "null", and an empty string/list/dict gives
490    // the model nothing to answer. A number or bool is left alone (upstream instructions validation).
491    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            // A label in the list form is used as the answer key and rendered as option text, so a
540            // nested list or object label has no meaning here. In the dict form keys are always
541            // strings, so only the list form can carry one. Reject it, naming the question and the
542            // label, rather than letting it be JSON-stringified into a key (upstream #425). The dict
543            // form (JSON object) cannot hit this: its keys are strings by construction.
544            if let Some(Value::Array(a)) = crit {
545                // Each list label becomes an answer key exactly as `to_internal` builds it: a string
546                // stays itself, anything else is rendered with `py_json`. Two labels that land on
547                // one key made the model score fewer options than the caller wrote and the response
548                // carry fewer probabilities, without a word — the same silent-shape class as the
549                // type and null checks (upstream #425 duplicate-label fix). Detected on the rendered
550                // key, which is what Rust actually uses to distinguish options.
551                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                        // A null label is neither valid option text nor a usable answer key: it
558                        // would render as the text "null" while its key is the JSON string "null",
559                        // so a client cannot tell it from the string "null". `""` is left alone,
560                        // because unlike a null it round-trips (upstream: reject a null label).
561                        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            // A null level would silently drop that description; reject it and name the index so
617            // every level gets described, index 0 first (upstream: reject a null score level).
618            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                // `render_options` reads only `false`/`true` out by name, so a dict keyed any other
634                // way is not a noul description at all — it used to be silently dropped and replaced
635                // with the defaults (#156). A noul is a boolean question, so those are its only keys.
636                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        // Slot s shows option `order[s]`. Anything other than a permutation of the option indices
653        // would either drop an option or show one twice, so reject it here rather than let it reach
654        // the encoder (upstream option_order validation). The permutation is checked on the integer
655        // entries only: a non-integer entry is dropped, so the surviving set falls short of range(n)
656        // and the whole order is rejected — the same as Python's filtered `sorted(...)` compare.
657        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
695/// How many options a validated question definition renders to. Mirrors `render_options`: a choice
696/// has one option per criterion, a score one per level, and a noul is always the pair [false, true].
697/// Used to validate `option_order` (upstream `_option_count`).
698fn 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
709/// Normalize a validated question into the internal form.
710fn 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    // Validated as a permutation of range(n) above, so every entry is a non-negative integer.
746    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
761/// Resolve the four checkpoint files from a local dir or a Hugging Face repo id.
762fn 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    // An empty token (explicit or from an empty `HF_TOKEN`) is treated as no token, so we never
814    // send an empty `Bearer` header to the hub (parity with upstream `token or HF_TOKEN or None`).
815    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        // A null level would silently drop that description; it must be rejected, naming the index.
853        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        // A list/object label in the list form is used as the answer key: reject it as a named
868        // caller error, naming the question and the label index (upstream #425).
869        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        // Strings, numbers and bools are all valid answer keys.
883        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        // A null label renders as the text "null" while its answer key is the JSON string "null",
894        // so a client cannot tell it from the string "null"; reject it (upstream signoff/label fix).
895        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        // Two labels that land on one answer key make the model score fewer options than were
904        // written and the response carry fewer probabilities, silently (upstream #425 duplicate fix).
905        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        // `null` would serialize as the text "null"; an empty string/list/dict gives the model
924        // nothing to answer (upstream instructions validation).
925        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        // A number is left alone, matching Python's `isinstance(ins, (str, dict, list, int, float))`.
941        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        // Not a permutation of range(2): out of range, wrong length, or a non-integer entry.
948        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        // A noul always has two options, so [1, 0] is the only non-canonical order.
972        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        // A numeric or structured level comes back as the JSON text the model was shown, not the
979        // raw JSON type (upstream render_criterion legend fix). Verified through the internal form.
980        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}