1use 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#[derive(Debug, Clone, Default)]
21pub struct LoadOptions {
22 pub device: Option<String>,
24 pub token: Option<String>,
26 pub subfolder: Option<String>,
28}
29
30#[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 pub routing: Option<Value>,
39}
40
41impl SystemOneResult {
42 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
70pub 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 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 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 pub fn predict(&self, state: &State, questions: &Questions) -> Result<SystemOneResult> {
129 self.system_one(state, questions)
130 }
131
132 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 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 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 _ => Device::Cpu,
422 }
423}
424
425fn 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
524fn 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
568fn 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}