Skip to main content

laya/
agent.rs

1//! The public inference runtime. Prompt and result formats follow upstream Laya.
2
3use candle_core::{DType, Device, Tensor};
4use candle_nn::VarBuilder;
5use laya_core::calibrate::{calibrated_softmax, confidence_from_probs};
6use laya_core::prompt::{build_prefix, extend_with_state, serialize_state, Prefix, Sequence};
7use laya_core::pycompat::round4;
8use laya_core::question::{Question, QuestionKind};
9use laya_core::Tokenizer;
10use rayon::prelude::*;
11use serde_json::{Map, Value};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::Mutex;
15
16use crate::config::{AgentConfig, EncoderConfig};
17use crate::model::DecisionModel;
18use crate::weights::SanitizedBackend;
19use crate::{Error, Result};
20
21/// Files every checkpoint must contain.
22const REQUIRED_FILES: [&str; 3] = [
23    "model.safetensors",
24    "rl_agent_config.json",
25    "encoder/config.json",
26];
27
28/// Questions per forward pass, matching upstream's default.
29const DEFAULT_BATCH_SIZE: usize = 16;
30/// Upstream bounds its prefix cache at this many questions.
31const PREFIX_CACHE_CAPACITY: usize = 128;
32/// Below this many questions, threading costs more than it saves.
33const PARALLEL_THRESHOLD: usize = 4;
34
35/// Parse a dtype name the way the Python API spells it.
36pub fn parse_dtype(name: &str) -> Result<DType> {
37    match name {
38        "float32" => Ok(DType::F32),
39        "float16" => Ok(DType::F16),
40        "bfloat16" => Ok(DType::BF16),
41        other => Err(Error::Config(format!(
42            "dtype must be one of [\"float32\", \"float16\", \"bfloat16\"], got {other:?}"
43        ))),
44    }
45}
46
47/// One prepared question, ready for collation.
48#[derive(Clone, Debug)]
49struct Item {
50    ids: Vec<u32>,
51    markers: Vec<usize>,
52    qtype: QuestionKind,
53    truncated: bool,
54}
55
56/// A collated batch of prepared questions.
57#[derive(Debug)]
58struct Batch {
59    input_ids: Tensor,
60    attention_mask: Tensor,
61    marker_pos: Tensor,
62    marker_mask: Tensor,
63    qtype: Tensor,
64}
65
66/// One answered question.
67///
68/// Values are stored already rounded to four decimals, matching what the
69/// upstream API returns.
70#[derive(Clone, Debug, PartialEq)]
71pub struct Answer {
72    pub kind: QuestionKind,
73    pub confidence: f64,
74    pub act_probability: f64,
75    /// Option labels in index order: choice labels, or `"0"`, `"1"`, … for a score.
76    pub labels: Vec<String>,
77    /// Probability per option, aligned with `labels`.
78    pub probabilities: Vec<f64>,
79    /// Selected label, for `choice`.
80    pub choice: Option<String>,
81    /// Expected zero-based rubric level, for `score`.
82    pub score: Option<f64>,
83    /// Rubric legend, for `score`.
84    pub legend: Option<Map<String, Value>>,
85    /// P(true), for `noul`.
86    pub noul: Option<f64>,
87}
88
89impl Answer {
90    /// Render in upstream's exact key order.
91    pub fn to_value(&self) -> Value {
92        let mut out = Map::new();
93        out.insert("type".into(), Value::String(self.kind.name().into()));
94        out.insert("confidence".into(), number(self.confidence));
95        let mut action = Map::new();
96        action.insert("act_probability".into(), number(self.act_probability));
97        out.insert("action".into(), Value::Object(action));
98
99        match self.kind {
100            QuestionKind::Choice => {
101                out.insert(
102                    "choice".into(),
103                    Value::String(self.choice.clone().unwrap_or_default()),
104                );
105                out.insert("probabilities".into(), self.probability_map());
106            }
107            QuestionKind::Score => {
108                out.insert("score".into(), number(self.score.unwrap_or(0.0)));
109                out.insert(
110                    "legend".into(),
111                    Value::Object(self.legend.clone().unwrap_or_default()),
112                );
113                out.insert("probabilities".into(), self.probability_map());
114            }
115            QuestionKind::Noul => {
116                out.insert("noul".into(), number(self.noul.unwrap_or(0.0)));
117            }
118        }
119        Value::Object(out)
120    }
121
122    fn probability_map(&self) -> Value {
123        let mut map = Map::new();
124        for (label, probability) in self.labels.iter().zip(&self.probabilities) {
125            map.insert(label.clone(), number(*probability));
126        }
127        Value::Object(map)
128    }
129}
130
131/// Serialize a rounded float, preserving CPython's `1.0` rather than `1`.
132fn number(value: f64) -> Value {
133    serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number)
134}
135
136/// The full result of one `predict` call.
137#[derive(Clone, Debug)]
138pub struct Prediction {
139    pub model: String,
140    /// Answers in question order.
141    pub answers: Map<String, Value>,
142    /// Typed answers, same order and keys as `answers`.
143    pub typed: Vec<(String, Answer)>,
144    /// Total across every sequence in the call, as upstream reports it.
145    pub input_tokens: usize,
146    pub output_tokens: usize,
147    /// Token count of each question's sequence, in question order.
148    ///
149    /// `input_tokens` is a batch total, so it cannot distinguish several short
150    /// sequences from one that was cut at `max_len`; these can.
151    pub sequence_lengths: Vec<usize>,
152    /// True when any sequence lost part of its state to `max_len`.
153    pub truncated: bool,
154    /// Present only on results produced through a [`crate::Router`].
155    pub routing: Option<Value>,
156}
157
158impl Prediction {
159    /// Render in upstream's exact shape.
160    pub fn to_value(&self) -> Value {
161        let mut out = Map::new();
162        out.insert("model".into(), Value::String(self.model.clone()));
163        out.insert("answers".into(), Value::Object(self.answers.clone()));
164        let mut usage = Map::new();
165        usage.insert("input_tokens".into(), Value::from(self.input_tokens));
166        usage.insert("output_tokens".into(), Value::from(self.output_tokens));
167        out.insert("usage".into(), Value::Object(usage));
168        if let Some(routing) = &self.routing {
169            out.insert("routing".into(), routing.clone());
170        }
171        Value::Object(out)
172    }
173
174    /// Upstream's shape plus the per-sequence usage detail.
175    ///
176    /// Kept separate from [`Self::to_value`] so the default rendering stays
177    /// byte-compatible with upstream, which emits only the two token counts.
178    pub fn to_value_detailed(&self) -> Value {
179        let mut out = self.to_value();
180        if let Some(usage) = out.get_mut("usage").and_then(|usage| usage.as_object_mut()) {
181            usage.insert(
182                "sequence_lengths".into(),
183                Value::from(self.sequence_lengths.clone()),
184            );
185            usage.insert("truncated".into(), Value::Bool(self.truncated));
186        }
187        out
188    }
189
190    /// Look up one answer by question id.
191    pub fn answer(&self, question_id: &str) -> Option<&Answer> {
192        self.typed
193            .iter()
194            .find(|(id, _)| id == question_id)
195            .map(|(_, answer)| answer)
196    }
197}
198
199/// Bounded tokenized-prefix reuse.
200///
201/// Only the tokenized *question* prefix is cached; encoder states and
202/// predictions never are, so every question still gets its own forward pass.
203/// The key is the question's content rather than upstream's tokenizer identity,
204/// which makes the cache safe to share and to persist.
205#[derive(Debug, Default)]
206struct PrefixCache {
207    entries: HashMap<String, Prefix>,
208    order: Vec<String>,
209}
210
211impl PrefixCache {
212    fn key(question: &Question, head_max_len: usize) -> String {
213        let mut key = String::with_capacity(64);
214        key.push_str(question.kind.name());
215        key.push('\u{1}');
216        key.push_str(&head_max_len.to_string());
217        key.push('\u{1}');
218        key.push_str(&question.instructions);
219        for option in question.render_options() {
220            key.push('\u{1}');
221            key.push_str(&option);
222        }
223        key
224    }
225
226    fn get_or_insert(
227        &mut self,
228        question: &Question,
229        head_max_len: usize,
230        build: impl FnOnce() -> Result<Prefix>,
231    ) -> Result<Prefix> {
232        let key = Self::key(question, head_max_len);
233        if let Some(prefix) = self.entries.get(&key) {
234            let prefix = prefix.clone();
235            self.touch(&key);
236            return Ok(prefix);
237        }
238        let prefix = build()?;
239        self.entries.insert(key.clone(), prefix.clone());
240        self.order.push(key);
241        while self.order.len() > PREFIX_CACHE_CAPACITY {
242            let evicted = self.order.remove(0);
243            self.entries.remove(&evicted);
244        }
245        Ok(prefix)
246    }
247
248    fn touch(&mut self, key: &str) {
249        if let Some(index) = self.order.iter().position(|entry| entry == key) {
250            let key = self.order.remove(index);
251            self.order.push(key);
252        }
253    }
254}
255
256/// Builder for [`Agent`].
257pub struct AgentBuilder {
258    dtype: DType,
259    device: Device,
260    batch_size: usize,
261    pad_to_multiple: Option<usize>,
262    cache_prompts: bool,
263}
264
265impl Default for AgentBuilder {
266    fn default() -> Self {
267        Self {
268            dtype: DType::F16,
269            device: Device::Cpu,
270            batch_size: DEFAULT_BATCH_SIZE,
271            pad_to_multiple: None,
272            cache_prompts: false,
273        }
274    }
275}
276
277impl AgentBuilder {
278    pub fn new() -> Self {
279        Self::default()
280    }
281
282    /// `"float32"`, `"float16"` (the default) or `"bfloat16"`.
283    pub fn dtype(mut self, dtype: DType) -> Self {
284        self.dtype = dtype;
285        self
286    }
287
288    pub fn device(mut self, device: Device) -> Self {
289        self.device = device;
290        self
291    }
292
293    /// Questions per forward pass. Defaults to 16, as upstream does.
294    pub fn batch_size(mut self, batch_size: usize) -> Self {
295        self.batch_size = batch_size.max(1);
296        self
297    }
298
299    /// Pad sequence length up to a multiple, to reduce shape churn.
300    pub fn pad_to_multiple(mut self, pad_to_multiple: Option<usize>) -> Self {
301        self.pad_to_multiple = pad_to_multiple.filter(|value| *value >= 1);
302        self
303    }
304
305    /// Reuse tokenized question prefixes across calls.
306    pub fn cache_prompts(mut self, cache_prompts: bool) -> Self {
307        self.cache_prompts = cache_prompts;
308        self
309    }
310
311    /// Load a checkpoint from a local directory.
312    pub fn build(self, model_dir: impl AsRef<Path>) -> Result<Agent> {
313        Agent::load(model_dir.as_ref(), self)
314    }
315}
316
317/// A loaded Laya checkpoint.
318pub struct Agent {
319    model_dir: PathBuf,
320    encoder_cfg: EncoderConfig,
321    agent_cfg: AgentConfig,
322    tokenizer: Tokenizer,
323    model: DecisionModel,
324    device: Device,
325    dtype: DType,
326    batch_size: usize,
327    pad_to_multiple: Option<usize>,
328    prefix_cache: Option<Mutex<PrefixCache>>,
329}
330
331impl std::fmt::Debug for Agent {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        f.debug_struct("Agent")
334            .field("model_dir", &self.model_dir)
335            .field("dtype", &self.dtype)
336            .field("batch_size", &self.batch_size)
337            .finish_non_exhaustive()
338    }
339}
340
341impl Agent {
342    /// Configure a load.
343    pub fn builder() -> AgentBuilder {
344        AgentBuilder::new()
345    }
346
347    /// Load a checkpoint from a local directory with default settings.
348    pub fn from_dir(model_dir: impl AsRef<Path>) -> Result<Self> {
349        AgentBuilder::new().build(model_dir)
350    }
351
352    fn load(model_dir: &Path, options: AgentBuilder) -> Result<Self> {
353        for name in REQUIRED_FILES {
354            let path = model_dir.join(name);
355            if !path.is_file() {
356                return Err(Error::IncompleteCheckpoint(path));
357            }
358        }
359
360        let agent_raw: Value = serde_json::from_str(&std::fs::read_to_string(
361            model_dir.join("rl_agent_config.json"),
362        )?)?;
363        let encoder_raw: Value = serde_json::from_str(&std::fs::read_to_string(
364            model_dir.join("encoder/config.json"),
365        )?)?;
366
367        let encoder_cfg = EncoderConfig::from_value(&encoder_raw)?;
368        let agent_cfg = AgentConfig::from_value(&agent_raw, encoder_cfg.max_position_embeddings)?;
369        let tokenizer = Tokenizer::from_dir(model_dir.join("tokenizer"))?;
370
371        // Memory-map the weights and expose them under sanitized names, so no
372        // copy of the checkpoint is made just to rename parameters.
373        let weights_path = model_dir.join("model.safetensors");
374        let mmaped = unsafe {
375            candle_core::safetensors::MmapedSafetensors::new(&weights_path)
376                .map_err(Error::Candle)?
377        };
378        let stored_names: Vec<String> =
379            mmaped.tensors().into_iter().map(|(name, _)| name).collect();
380        let backend = SanitizedBackend::new(Box::new(mmaped), stored_names);
381        let vb = VarBuilder::from_backend(Box::new(backend), options.dtype, options.device.clone());
382
383        let model =
384            DecisionModel::load(vb, &encoder_cfg, &agent_cfg, options.dtype, &options.device)?;
385
386        Ok(Self {
387            model_dir: model_dir.to_path_buf(),
388            encoder_cfg,
389            agent_cfg,
390            tokenizer,
391            model,
392            device: options.device,
393            dtype: options.dtype,
394            batch_size: options.batch_size,
395            pad_to_multiple: options.pad_to_multiple,
396            prefix_cache: options
397                .cache_prompts
398                .then(|| Mutex::new(PrefixCache::default())),
399        })
400    }
401
402    pub fn model_dir(&self) -> &Path {
403        &self.model_dir
404    }
405
406    pub fn config(&self) -> &AgentConfig {
407        &self.agent_cfg
408    }
409
410    pub fn encoder_config(&self) -> &EncoderConfig {
411        &self.encoder_cfg
412    }
413
414    pub fn tokenizer(&self) -> &Tokenizer {
415        &self.tokenizer
416    }
417
418    pub fn device(&self) -> &Device {
419        &self.device
420    }
421
422    pub fn dtype(&self) -> DType {
423        self.dtype
424    }
425
426    /// Validate and tokenize every question against a state.
427    fn prepare(
428        &self,
429        state: &Value,
430        questions: &Map<String, Value>,
431    ) -> Result<(Vec<Item>, Vec<Question>)> {
432        if questions.is_empty() {
433            return Ok((Vec::new(), Vec::new()));
434        }
435        let max_len = self.agent_cfg.max_len;
436        let head_max_len = self.agent_cfg.head_max_len;
437
438        // The state is tokenized once and shared by every question, which is
439        // where the prefix cache earns its keep.
440        let state_text = serialize_state(state).replace(self.tokenizer.mask_token.as_str(), " ");
441        let state_ids = self.tokenizer.encode(&state_text)?;
442
443        let entries: Vec<(&String, &Value)> = questions.iter().collect();
444        let build_one = |(qid, definition): &(&String, &Value)| -> Result<(Item, Question)> {
445            let question = Question::from_value(definition)?;
446            let option_count = question.render_options().len();
447
448            let prefix = match &self.prefix_cache {
449                Some(cache) => {
450                    let mut cache = cache.lock().expect("prefix cache is not poisoned");
451                    cache.get_or_insert(&question, head_max_len, || {
452                        build_prefix(&self.tokenizer, &question, head_max_len).map_err(Error::Core)
453                    })?
454                }
455                None => build_prefix(&self.tokenizer, &question, head_max_len)?,
456            };
457
458            let Sequence {
459                ids,
460                markers,
461                truncated,
462            } = extend_with_state(&self.tokenizer, &prefix, &state_ids, max_len, false);
463            if markers.len() != option_count {
464                return Err(Error::Core(laya_core::Error::Question(format!(
465                    "Question {qid:?} has too many options for the token budget"
466                ))));
467            }
468            let qtype = question.kind;
469            Ok((
470                Item {
471                    ids,
472                    markers,
473                    qtype,
474                    truncated,
475                },
476                question,
477            ))
478        };
479
480        // Tokenization is independent per question and dominates preparation
481        // time for large requests, so fan it out once it is worth the overhead.
482        let prepared: Vec<(Item, Question)> =
483            if entries.len() >= PARALLEL_THRESHOLD && self.prefix_cache.is_none() {
484                entries.par_iter().map(build_one).collect::<Result<_>>()?
485            } else {
486                entries.iter().map(build_one).collect::<Result<_>>()?
487            };
488
489        Ok(prepared.into_iter().unzip())
490    }
491
492    /// Pad a chunk of prepared questions into dense tensors.
493    fn collate(&self, items: &[Item]) -> Result<Batch> {
494        let rows = items.len();
495        let mut length = items.iter().map(|item| item.ids.len()).max().unwrap_or(0);
496        if let Some(multiple) = self.pad_to_multiple {
497            length = length.div_ceil(multiple) * multiple;
498            length = std::cmp::min(length, self.agent_cfg.max_len);
499        }
500        // At least two marker slots, so the top-2 features are always defined.
501        let slots = std::cmp::max(
502            2,
503            items
504                .iter()
505                .map(|item| item.markers.len())
506                .max()
507                .unwrap_or(0),
508        );
509
510        let pad_id = self.tokenizer.pad_token_id;
511        let mut input_ids = vec![pad_id; rows * length];
512        let mut attention_mask = vec![0u8; rows * length];
513        let mut marker_pos = vec![0i64; rows * slots];
514        let mut marker_mask = vec![0u8; rows * slots];
515        let mut qtype = Vec::with_capacity(rows);
516
517        for (row, item) in items.iter().enumerate() {
518            let used = std::cmp::min(item.ids.len(), length);
519            input_ids[row * length..row * length + used].copy_from_slice(&item.ids[..used]);
520            attention_mask[row * length..row * length + used].fill(1);
521            for (slot, marker) in item.markers.iter().enumerate() {
522                marker_pos[row * slots + slot] = *marker as i64;
523                marker_mask[row * slots + slot] = 1;
524            }
525            qtype.push(item.qtype.index() as u32);
526        }
527
528        Ok(Batch {
529            input_ids: Tensor::from_vec(input_ids, (rows, length), &self.device)?,
530            attention_mask: Tensor::from_vec(attention_mask, (rows, length), &self.device)?,
531            marker_pos: Tensor::from_vec(marker_pos, (rows, slots), &self.device)?,
532            marker_mask: Tensor::from_vec(marker_mask, (rows, slots), &self.device)?,
533            qtype: Tensor::from_vec(qtype, rows, &self.device)?,
534        })
535    }
536
537    /// Answer every question about a state.
538    pub fn predict(&self, state: &Value, questions: &Value) -> Result<Prediction> {
539        let questions = questions.as_object().ok_or_else(|| {
540            Error::Core(laya_core::Error::Question(
541                "questions must be a dictionary keyed by question id".into(),
542            ))
543        })?;
544        self.predict_map(state, questions)
545    }
546
547    /// Alias for [`Agent::predict`], matching upstream's `system_one`.
548    pub fn system_one(&self, state: &Value, questions: &Value) -> Result<Prediction> {
549        self.predict(state, questions)
550    }
551
552    /// Answer every question, taking the question map directly.
553    pub fn predict_map(&self, state: &Value, questions: &Map<String, Value>) -> Result<Prediction> {
554        let (items, internal) = self.prepare(state, questions)?;
555        let question_ids: Vec<&String> = questions.keys().collect();
556
557        let mut answers = Map::new();
558        let mut typed = Vec::with_capacity(items.len());
559
560        for (chunk_index, chunk) in items.chunks(self.batch_size).enumerate() {
561            let batch = self.collate(chunk)?;
562            let output = self.model.forward(
563                &batch.input_ids,
564                &batch.attention_mask,
565                &batch.marker_pos,
566                &batch.marker_mask,
567                &batch.qtype,
568            )?;
569
570            let logits: Vec<f32> = output.logits.flatten_all()?.to_vec1()?;
571            let action: Vec<f32> = output.action.flatten_all()?.to_vec1()?;
572            if logits.iter().chain(action.iter()).any(|v| !v.is_finite()) {
573                return Err(Error::NonFinite);
574            }
575
576            let slots = output.logits.dim(1)?;
577            let act_classes = output.action.dim(1)?;
578            let offset = chunk_index * self.batch_size;
579
580            for (row, item) in chunk.iter().enumerate() {
581                let index = offset + row;
582                let qid = question_ids[index];
583                let question = &internal[index];
584                let k = item.markers.len();
585
586                let temperature = self.agent_cfg.temperature_for(item.qtype, k);
587                let row_logits = &logits[row * slots..row * slots + k];
588                let probabilities = calibrated_softmax(row_logits, temperature);
589
590                // Upstream softmaxes the action head in NumPy, after the model.
591                let act_row = &action[row * act_classes..(row + 1) * act_classes];
592                let act_probabilities = laya_core::calibrate::softmax(act_row);
593                let act_probability = round4(act_probabilities.first().copied().unwrap_or(0.0));
594
595                let answer = build_answer(question, &probabilities, k, act_probability);
596                answers.insert(qid.to_string(), answer.to_value());
597                typed.push((qid.to_string(), answer));
598            }
599        }
600
601        Ok(Prediction {
602            model: "laya-rl-agent".into(),
603            answers,
604            typed,
605            input_tokens: items.iter().map(|item| item.ids.len()).sum(),
606            output_tokens: 0,
607            sequence_lengths: items.iter().map(|item| item.ids.len()).collect(),
608            truncated: items.iter().any(|item| item.truncated),
609            routing: None,
610        })
611    }
612}
613
614/// Assemble one answer from calibrated probabilities.
615fn build_answer(
616    question: &Question,
617    probabilities: &[f64],
618    k: usize,
619    act_probability: f64,
620) -> Answer {
621    let rounded: Vec<f64> = probabilities.iter().copied().map(round4).collect();
622    let confidence = round4(confidence_from_probs(probabilities, k));
623
624    match question.kind {
625        QuestionKind::Choice => {
626            let labels = question.choice_labels().unwrap_or_default();
627            let best = probabilities
628                .iter()
629                .enumerate()
630                .fold((0usize, f64::NEG_INFINITY), |acc, (index, value)| {
631                    // `argmax` keeps the first maximum, as NumPy does.
632                    if *value > acc.1 {
633                        (index, *value)
634                    } else {
635                        acc
636                    }
637                })
638                .0;
639            Answer {
640                kind: QuestionKind::Choice,
641                confidence,
642                act_probability,
643                choice: labels.get(best).cloned(),
644                labels,
645                probabilities: rounded,
646                score: None,
647                legend: None,
648                noul: None,
649            }
650        }
651        QuestionKind::Score => {
652            let expected: f64 = probabilities
653                .iter()
654                .enumerate()
655                .map(|(index, value)| index as f64 * value)
656                .sum();
657            Answer {
658                kind: QuestionKind::Score,
659                confidence,
660                act_probability,
661                labels: (0..probabilities.len()).map(|i| i.to_string()).collect(),
662                probabilities: rounded,
663                choice: None,
664                score: Some(round4(expected)),
665                legend: question.score_legend(),
666                noul: None,
667            }
668        }
669        QuestionKind::Noul => {
670            let p_true = probabilities.get(1).copied().unwrap_or(0.0);
671            Answer {
672                kind: QuestionKind::Noul,
673                // Upstream overwrites the entropy confidence for noul.
674                confidence: round4(p_true.max(1.0 - p_true)),
675                act_probability,
676                labels: vec!["false".into(), "true".into()],
677                probabilities: rounded,
678                choice: None,
679                score: None,
680                legend: None,
681                noul: Some(round4(p_true)),
682            }
683        }
684    }
685}