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    build_sequence, clamp_temperature, collate_items, confidence_from_probs, render_options,
12    round4, softmax, temp_bucket, InternalQ, Item, QType,
13};
14use crate::error::{LayaError, Result};
15use crate::model::DecisionModel;
16use crate::tokenizer::LayaTokenizer;
17use crate::{Questions, State};
18
19/// Options for [`Agent::load`].
20#[derive(Debug, Clone, Default)]
21pub struct LoadOptions {
22    /// Compute device: `None`/`"cpu"` for CPU (the only backend built by default).
23    pub device: Option<String>,
24    /// Hugging Face token (falls back to `HF_TOKEN`).
25    pub token: Option<String>,
26    /// Subfolder within a bundled repo (e.g. `"multilingual"`).
27    pub subfolder: Option<String>,
28}
29
30/// The result of a single-state evaluation (Jev-shaped).
31#[derive(Debug, Clone)]
32pub struct SystemOneResult {
33    pub model: String,
34    pub answers: IndexMap<String, Value>,
35    pub input_tokens: usize,
36    pub output_tokens: usize,
37    /// Set by [`crate::Router::predict`]; the routing decision as JSON.
38    pub routing: Option<Value>,
39}
40
41impl SystemOneResult {
42    /// Serialise to the Jev wire shape: `{model, answers, usage, routing?}`.
43    pub fn to_json(&self) -> Value {
44        let answers: Map<String, Value> = self
45            .answers
46            .iter()
47            .map(|(k, v)| (k.clone(), v.clone()))
48            .collect();
49        let mut out = json!({
50            "model": self.model,
51            "answers": Value::Object(answers),
52            "usage": {"input_tokens": self.input_tokens, "output_tokens": self.output_tokens},
53        });
54        if let Some(r) = &self.routing {
55            out.as_object_mut()
56                .unwrap()
57                .insert("routing".to_string(), r.clone());
58        }
59        out
60    }
61}
62
63struct CheckpointFiles {
64    config: PathBuf,
65    weights: PathBuf,
66    encoder_config: PathBuf,
67    tokenizer: PathBuf,
68}
69
70/// System-1 decision model runtime.
71pub struct Agent {
72    model: DecisionModel,
73    tok: LayaTokenizer,
74    max_len: usize,
75    head_max_len: usize,
76    temperature: [f64; 3],
77    temperature_by_options: std::collections::HashMap<String, f64>,
78}
79
80impl Agent {
81    /// Load a Laya checkpoint from a local directory or a Hugging Face repo id.
82    pub fn load(model_id_or_path: &str, opts: LoadOptions) -> Result<Agent> {
83        let files = resolve_files(model_id_or_path, &opts)?;
84
85        let cfg: Value = serde_json::from_slice(&std::fs::read(&files.config)?)?;
86        let encoder_config: Value = serde_json::from_slice(&std::fs::read(&files.encoder_config)?)?;
87
88        let max_len = cfg.get("max_len").and_then(|v| v.as_u64()).unwrap_or(512) as usize;
89        let head_max_len = cfg
90            .get("head_max_len")
91            .and_then(|v| v.as_u64())
92            .unwrap_or(192) as usize;
93        let head_layers = cfg.get("head_layers").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
94        let n_act = cfg
95            .get("act_costs")
96            .and_then(|v| v.as_object())
97            .map(|m| m.len())
98            .unwrap_or(0)
99            + 1;
100
101        let temperature = load_temperature(&cfg);
102        let temperature_by_options = load_temperature_by_options(&cfg);
103
104        let device = resolve_device(opts.device.as_deref());
105        let model =
106            DecisionModel::load(&files.weights, &encoder_config, head_layers, n_act, device)?;
107        let tok = LayaTokenizer::from_file(&files.tokenizer)?;
108
109        Ok(Agent {
110            model,
111            tok,
112            max_len,
113            head_max_len,
114            temperature,
115            temperature_by_options,
116        })
117    }
118
119    /// Evaluate typed questions over one state in a single forward pass.
120    pub fn system_one(&self, state: &State, questions: &Questions) -> Result<SystemOneResult> {
121        Ok(self
122            .predict_batch(std::slice::from_ref(state), questions, None)?
123            .pop()
124            .expect("one state → one result"))
125    }
126
127    /// Alias for [`Agent::system_one`].
128    pub fn predict(&self, state: &State, questions: &Questions) -> Result<SystemOneResult> {
129        self.system_one(state, questions)
130    }
131
132    /// Evaluate the same questions over many states, sharing forward passes.
133    pub fn predict_batch(
134        &self,
135        states: &[State],
136        questions: &Questions,
137        batch_size: Option<usize>,
138    ) -> Result<Vec<SystemOneResult>> {
139        if states.is_empty() {
140            return Ok(Vec::new());
141        }
142        let ids: Vec<String> = questions.keys().cloned().collect();
143        if ids.is_empty() {
144            return Ok(states
145                .iter()
146                .map(|_| SystemOneResult {
147                    model: "laya-rl-agent".to_string(),
148                    answers: IndexMap::new(),
149                    input_tokens: 0,
150                    output_tokens: 0,
151                    routing: None,
152                })
153                .collect());
154        }
155
156        // Validate + normalize each question once (state-independent).
157        let mut internal: Vec<(String, InternalQ)> = Vec::with_capacity(ids.len());
158        for qid in &ids {
159            let qdef = &questions[qid];
160            check_question(qid, qdef)?;
161            internal.push((qid.clone(), to_internal(qdef)?));
162        }
163
164        let chunk = batch_size.filter(|&b| b > 0).unwrap_or(states.len());
165        let mut results = Vec::with_capacity(states.len());
166
167        for part in states.chunks(chunk) {
168            let mut per_state_items: Vec<Vec<Item>> = Vec::with_capacity(part.len());
169            for st in part {
170                per_state_items.push(self.encode_state(st, &internal)?);
171            }
172            let batch = collate_items(&per_state_items, self.tok_pad())
173                .ok_or_else(|| LayaError::Model("empty batch".to_string()))?;
174            let (logits, act) = self.model.forward(&batch)?;
175
176            let mut row = 0usize;
177            for items in &per_state_items {
178                let nrows = items.len();
179                let n_tokens: usize = (row..row + nrows)
180                    .map(|r| {
181                        batch.attention_mask[r]
182                            .iter()
183                            .map(|&x| x as usize)
184                            .sum::<usize>()
185                    })
186                    .sum();
187                let answers = self.decode(&logits, &act, items, &internal, row);
188                results.push(SystemOneResult {
189                    model: "laya-rl-agent".to_string(),
190                    answers,
191                    input_tokens: n_tokens,
192                    output_tokens: 0,
193                    routing: None,
194                });
195                row += nrows;
196            }
197        }
198        Ok(results)
199    }
200
201    fn tok_pad(&self) -> u32 {
202        use crate::common::Tokenizer;
203        self.tok.pad_id()
204    }
205
206    /// Mean-pooled encoder embeddings for a list of texts (`[n, hidden_size]`).
207    ///
208    /// Tokenizes with special tokens, truncates to `max_length`, runs the checkpoint encoder in
209    /// batches of `batch_size`, and mean-pools over non-padding tokens. Backs
210    /// [`crate::shortlist::embed_fn_from_agent`]; a dedicated bi-encoder will usually shortlist
211    /// better, but this needs only the loaded checkpoint.
212    pub fn embed(
213        &self,
214        texts: &[String],
215        max_length: usize,
216        batch_size: usize,
217    ) -> Result<Vec<Vec<f32>>> {
218        if texts.is_empty() {
219            return Ok(Vec::new());
220        }
221        let pad = self.tok_pad();
222        let mut out = Vec::with_capacity(texts.len());
223        for chunk in texts.chunks(batch_size.max(1)) {
224            let rows: Vec<Vec<u32>> = chunk
225                .iter()
226                .map(|t| self.tok.encode_with_special(t, max_length))
227                .collect();
228            let l = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
229            let mut ids = Vec::with_capacity(rows.len());
230            let mut att = Vec::with_capacity(rows.len());
231            for r in &rows {
232                let mut row = r.clone();
233                let mut a = vec![1u32; row.len()];
234                row.resize(l, pad);
235                a.resize(l, 0);
236                ids.push(row);
237                att.push(a);
238            }
239            out.extend(self.model.embed(&ids, &att)?);
240        }
241        Ok(out)
242    }
243
244    fn encode_state(&self, state: &State, internal: &[(String, InternalQ)]) -> Result<Vec<Item>> {
245        let truncate_left = state.is_array();
246        let mut items = Vec::with_capacity(internal.len());
247        for (qid, q) in internal {
248            let (seq, markers) = build_sequence(
249                &self.tok,
250                state,
251                q,
252                self.max_len,
253                self.head_max_len,
254                None,
255                truncate_left,
256            )?;
257            if markers.len() != render_options(q)?.len() {
258                return Err(LayaError::InvalidQuestion(format!(
259                    "question {:?} options exceed head_max_len={}",
260                    qid, self.head_max_len
261                )));
262            }
263            items.push(Item {
264                ids: seq,
265                markers,
266                qtype: q.t.index(),
267            });
268        }
269        Ok(items)
270    }
271
272    fn decode(
273        &self,
274        logits: &[Vec<f32>],
275        act: &[Vec<f32>],
276        items: &[Item],
277        internal: &[(String, InternalQ)],
278        offset: usize,
279    ) -> IndexMap<String, Value> {
280        let mut answers = IndexMap::new();
281        for (j, (qid, q)) in internal.iter().enumerate() {
282            let r = offset + j;
283            let k = items[j].markers.len();
284            let qt = q.t.index();
285            let t_scale = self
286                .temperature_by_options
287                .get(&temp_bucket(qt, k))
288                .copied()
289                .unwrap_or(self.temperature[qt]);
290
291            let z: Vec<f64> = logits[r][..k].iter().map(|&v| v as f64 / t_scale).collect();
292            let p = softmax(&z);
293            let conf = round4(confidence_from_probs(&p, k));
294
295            let act_p = softmax(&act[r].iter().map(|&v| v as f64).collect::<Vec<_>>());
296            let act_probability = round4(act_p.first().copied().unwrap_or(0.0));
297            let action = json!({ "act_probability": act_probability });
298
299            let answer = match q.t {
300                QType::Choice => {
301                    let keys: Vec<String> = q
302                        .crit
303                        .as_object()
304                        .map(|m| m.keys().cloned().collect())
305                        .unwrap_or_default();
306                    let best = argmax(&p);
307                    let probs: Map<String, Value> = keys
308                        .iter()
309                        .zip(p.iter())
310                        .map(|(kk, &v)| (kk.clone(), json!(round4(v))))
311                        .collect();
312                    json!({
313                        "type": "choice",
314                        "choice": keys.get(best).cloned().unwrap_or_default(),
315                        "probabilities": Value::Object(probs),
316                        "confidence": conf,
317                        "action": action,
318                    })
319                }
320                QType::Score => {
321                    let exp: f64 = p.iter().enumerate().map(|(i, &v)| i as f64 * v).sum();
322                    let legend: Map<String, Value> = q
323                        .crit
324                        .as_array()
325                        .map(|a| {
326                            a.iter()
327                                .enumerate()
328                                .map(|(i, c)| (i.to_string(), c.clone()))
329                                .collect()
330                        })
331                        .unwrap_or_default();
332                    let probs: Map<String, Value> = p
333                        .iter()
334                        .enumerate()
335                        .map(|(i, &v)| (i.to_string(), json!(round4(v))))
336                        .collect();
337                    json!({
338                        "type": "score",
339                        "score": round4(exp),
340                        "legend": Value::Object(legend),
341                        "probabilities": Value::Object(probs),
342                        "confidence": conf,
343                        "action": action,
344                    })
345                }
346                QType::Noul => {
347                    let p_true = p.get(1).copied().unwrap_or(0.0);
348                    json!({
349                        "type": "noul",
350                        "noul": round4(p_true),
351                        "confidence": round4(p_true.max(1.0 - p_true)),
352                        "action": action,
353                    })
354                }
355            };
356            answers.insert(qid.clone(), answer);
357        }
358        answers
359    }
360}
361
362fn argmax(p: &[f64]) -> usize {
363    let mut best = 0usize;
364    for i in 1..p.len() {
365        if p[i] > p[best] {
366            best = i;
367        }
368    }
369    best
370}
371
372fn load_temperature(cfg: &Value) -> [f64; 3] {
373    let arr = cfg.get("temperature").and_then(|v| v.as_array());
374    let mut out = [1.0; 3];
375    if let Some(a) = arr {
376        for (i, slot) in out.iter_mut().enumerate() {
377            if let Some(v) = a.get(i) {
378                *slot = clamp_temperature(v);
379            }
380        }
381    }
382    out
383}
384
385fn load_temperature_by_options(cfg: &Value) -> std::collections::HashMap<String, f64> {
386    cfg.get("temperature_by_options")
387        .and_then(|v| v.as_object())
388        .map(|m| {
389            m.iter()
390                .map(|(k, v)| (k.clone(), clamp_temperature(v)))
391                .collect()
392        })
393        .unwrap_or_default()
394}
395
396fn resolve_device(device: Option<&str>) -> Device {
397    match device.map(|d| d.trim().to_lowercase()).as_deref() {
398        Some("metal") => {
399            #[cfg(feature = "metal")]
400            {
401                match Device::new_metal(0) {
402                    Ok(d) => d,
403                    Err(e) => {
404                        eprintln!(
405                            "laya: Metal requested but unavailable ({e}); falling back to CPU."
406                        );
407                        Device::Cpu
408                    }
409                }
410            }
411            #[cfg(not(feature = "metal"))]
412            {
413                eprintln!(
414                    "laya: device=\"metal\" requested but the crate was built without the `metal` \
415                     feature; using CPU. Rebuild with --features metal."
416                );
417                Device::Cpu
418            }
419        }
420        // "cuda" and other accelerators are follow-ups; everything else runs on CPU.
421        _ => Device::Cpu,
422    }
423}
424
425/// Validate a question definition, matching `Agent._check_question`.
426fn check_question(qid: &str, qdef: &Value) -> Result<()> {
427    let obj = match qdef {
428        Value::Object(m) => m,
429        other => {
430            let kind = match other {
431                Value::Array(_) => "list",
432                Value::Null => "NoneType",
433                Value::String(_) => "str",
434                Value::Bool(_) => "bool",
435                Value::Number(_) => "number",
436                Value::Object(_) => unreachable!(),
437            };
438            return Err(LayaError::InvalidQuestion(format!(
439                "question {:?}: definition must be a dict, got {}",
440                qid, kind
441            )));
442        }
443    };
444    let t = obj.get("type").and_then(|v| v.as_str());
445    let t = match t.and_then(QType::from_name) {
446        Some(t) => t,
447        None => {
448            return Err(LayaError::InvalidQuestion(format!(
449                "question {:?}: unknown type {:?}; use one of [\"choice\", \"noul\", \"score\"]",
450                qid,
451                obj.get("type").unwrap_or(&Value::Null)
452            )))
453        }
454    };
455    if !obj.contains_key("instructions") {
456        return Err(LayaError::InvalidQuestion(format!(
457            "question {:?}: no 'instructions'; add the text the model should answer",
458            qid
459        )));
460    }
461    let crit = obj.get("criteria");
462    match t {
463        QType::Choice => {
464            let ok = matches!(crit, Some(Value::Object(_)) | Some(Value::Array(_)));
465            if !ok {
466                return Err(LayaError::InvalidQuestion(format!(
467                    "question {:?}: a choice question takes 'criteria' as a dict of label -> description, or a list of labels",
468                    qid
469                )));
470            }
471            let empty = match crit {
472                Some(Value::Object(m)) => m.is_empty(),
473                Some(Value::Array(a)) => a.is_empty(),
474                _ => true,
475            };
476            if empty {
477                return Err(LayaError::InvalidQuestion(format!(
478                    "question {:?}: a choice question needs at least one criterion",
479                    qid
480                )));
481            }
482        }
483        QType::Score => {
484            let arr = match crit {
485                Some(Value::Array(a)) => a,
486                _ => {
487                    return Err(LayaError::InvalidQuestion(format!(
488                        "question {:?}: a score question takes 'criteria' as a list of level descriptions, index 0 first",
489                        qid
490                    )))
491                }
492            };
493            if arr.is_empty() {
494                return Err(LayaError::InvalidQuestion(format!(
495                    "question {:?}: a score question needs at least one level",
496                    qid
497                )));
498            }
499        }
500        QType::Noul => {
501            if let Some(c) = crit {
502                if !c.is_null() && !c.is_object() {
503                    return Err(LayaError::InvalidQuestion(format!(
504                        "question {:?}: a noul question takes 'criteria' as a dict with optional 'true'/'false' descriptions, or omits it",
505                        qid
506                    )));
507                }
508            }
509        }
510    }
511    if obj.contains_key("labels") {
512        if t != QType::Noul {
513            return Err(LayaError::InvalidQuestion(format!(
514                "question {:?}: 'labels' is only supported for noul questions",
515                qid
516            )));
517        }
518        crate::common::resolve_noul_labels(obj.get("labels"))
519            .map_err(|e| LayaError::InvalidQuestion(format!("question {:?}: {}", qid, e)))?;
520    }
521    Ok(())
522}
523
524/// Normalize a validated question into the internal form.
525fn to_internal(qdef: &Value) -> Result<InternalQ> {
526    let obj = qdef.as_object().expect("validated as object");
527    let t = QType::from_name(obj.get("type").and_then(|v| v.as_str()).unwrap_or("")).unwrap();
528    let mut crit = obj.get("criteria").cloned().unwrap_or(Value::Null);
529    match t {
530        QType::Choice => {
531            if let Value::Array(a) = &crit {
532                let mut m = Map::new();
533                for c in a {
534                    let key = c
535                        .as_str()
536                        .map(|s| s.to_string())
537                        .unwrap_or_else(|| crate::common::py_json(c));
538                    m.insert(key, Value::Null);
539                }
540                crit = Value::Object(m);
541            }
542        }
543        QType::Noul => {
544            if let Value::Object(m) = &crit {
545                let lowered: Map<String, Value> = m
546                    .iter()
547                    .map(|(k, v)| (k.to_lowercase(), v.clone()))
548                    .collect();
549                crit = Value::Object(lowered);
550            }
551        }
552        QType::Score => {}
553    }
554    let ins = match obj.get("instructions") {
555        Some(Value::String(s)) => s.clone(),
556        Some(other) => crate::common::py_json(other),
557        None => String::new(),
558    };
559    let labels = obj.get("labels").cloned();
560    Ok(InternalQ {
561        t,
562        ins,
563        crit,
564        labels,
565    })
566}
567
568/// Resolve the four checkpoint files from a local dir or a Hugging Face repo id.
569fn resolve_files(model_id_or_path: &str, opts: &LoadOptions) -> Result<CheckpointFiles> {
570    let base = Path::new(model_id_or_path);
571    if base.exists() {
572        let dir = match &opts.subfolder {
573            Some(sub) => base.join(sub),
574            None => base.to_path_buf(),
575        };
576        let config = require(dir.join("rl_agent_config.json"))?;
577        let weights = require(dir.join("model.safetensors"))?;
578        let encoder_config = if dir.join("encoder/config.json").exists() {
579            dir.join("encoder/config.json")
580        } else {
581            require(dir.join("config.json"))?
582        };
583        let tokenizer = if dir.join("tokenizer/tokenizer.json").exists() {
584            dir.join("tokenizer/tokenizer.json")
585        } else {
586            require(dir.join("tokenizer.json"))?
587        };
588        return Ok(CheckpointFiles {
589            config,
590            weights,
591            encoder_config,
592            tokenizer,
593        });
594    }
595    if model_id_or_path.starts_with('/')
596        || model_id_or_path.starts_with("./")
597        || model_id_or_path.starts_with("../")
598    {
599        return Err(LayaError::NotFound(format!(
600            "Local model path not found: {:?}.",
601            model_id_or_path
602        )));
603    }
604    download_files(model_id_or_path, opts)
605}
606
607fn require(p: PathBuf) -> Result<PathBuf> {
608    if p.exists() {
609        Ok(p)
610    } else {
611        Err(LayaError::NotFound(format!(
612            "missing checkpoint file: {}",
613            p.display()
614        )))
615    }
616}
617
618fn download_files(repo: &str, opts: &LoadOptions) -> Result<CheckpointFiles> {
619    use hf_hub::api::sync::ApiBuilder;
620    let token = opts
621        .token
622        .clone()
623        .or_else(|| std::env::var("HF_TOKEN").ok());
624    let api = ApiBuilder::new()
625        .with_token(token)
626        .build()
627        .map_err(|e| LayaError::Download(e.to_string()))?;
628    let repo_api = api.model(repo.to_string());
629    let prefix = match &opts.subfolder {
630        Some(sub) => format!("{}/", sub),
631        None => String::new(),
632    };
633    let get = |name: &str| -> Result<PathBuf> {
634        repo_api
635            .get(&format!("{}{}", prefix, name))
636            .map_err(|e| LayaError::Download(format!("{}{}: {}", prefix, name, e)))
637    };
638    let config = get("rl_agent_config.json")?;
639    let weights = get("model.safetensors")?;
640    let encoder_config = get("encoder/config.json")?;
641    let tokenizer = get("tokenizer/tokenizer.json").or_else(|_| get("tokenizer.json"))?;
642    Ok(CheckpointFiles {
643        config,
644        weights,
645        encoder_config,
646        tokenizer,
647    })
648}