Skip to main content

laya/
model.rs

1//! Native candle implementation of the Laya `DecisionModel`: a ModernBERT/mmBERT encoder
2//! (from `candle-transformers`) plus the decision head (`type_emb` + 2-layer pre-norm
3//! transformer + `scorer` + `act_head`). Ported from `DecisionModel` in `laya/common.py`
4//! and the `_HeadOnly` export in `laya-ts/scripts/export_onnx.py`.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use candle_core::{DType, Device, IndexOp, Tensor, D};
10use candle_nn::{LayerNorm, Linear, Module, VarBuilder};
11use candle_transformers::models::modernbert::{Config as MbConfig, ModernBert};
12
13use crate::common::CollatedBatch;
14use crate::error::{LayaError, Result};
15
16/// Head layer-norm epsilon (`nn.LayerNorm` default).
17const LN_EPS: f64 = 1e-5;
18/// Masked-option fill, matching `logits.masked_fill(~marker_mask, -1e4)`.
19const MASK_FILL: f64 = -1e4;
20
21/// `(logits, act_logits)` as row-major host vectors.
22pub type ForwardOutput = (Vec<Vec<f32>>, Vec<Vec<f32>>);
23
24fn e(err: candle_core::Error) -> LayaError {
25    LayaError::Model(err.to_string())
26}
27
28/// One pre-norm transformer encoder layer of the decision head
29/// (`nn.TransformerEncoderLayer(norm_first=True)`, FFN activation = ReLU).
30struct HeadLayer {
31    in_proj: Linear, // packed Q/K/V: weight [3d, d], bias [3d]
32    out_proj: Linear,
33    linear1: Linear, // [4d, d]
34    linear2: Linear, // [d, 4d]
35    norm1: LayerNorm,
36    norm2: LayerNorm,
37    n_heads: usize,
38    head_dim: usize,
39}
40
41impl HeadLayer {
42    fn self_attention(&self, x: &Tensor, add_mask: &Tensor) -> Result<Tensor> {
43        let (b, s, d) = x.dims3().map_err(e)?;
44        let qkv = self.in_proj.forward(x).map_err(e)?; // [b,s,3d]
45        let q = qkv.narrow(2, 0, d).map_err(e)?;
46        let k = qkv.narrow(2, d, d).map_err(e)?;
47        let v = qkv.narrow(2, 2 * d, d).map_err(e)?;
48        let split = |t: &Tensor| -> Result<Tensor> {
49            t.reshape((b, s, self.n_heads, self.head_dim))
50                .map_err(e)?
51                .transpose(1, 2)
52                .map_err(e)?
53                .contiguous()
54                .map_err(e) // [b,h,s,hd]
55        };
56        let q = split(&q)?;
57        let k = split(&k)?;
58        let v = split(&v)?;
59        let scale = (self.head_dim as f64).powf(-0.5);
60        let q = (q * scale).map_err(e)?;
61        let att = q
62            .matmul(&k.transpose(2, 3).map_err(e)?.contiguous().map_err(e)?)
63            .map_err(e)?; // [b,h,s,s]
64        let att = att.broadcast_add(add_mask).map_err(e)?; // add_mask [b,1,1,s]
65        let att = candle_nn::ops::softmax(&att, D::Minus1).map_err(e)?;
66        let ctx = att.matmul(&v).map_err(e)?; // [b,h,s,hd]
67        let ctx = ctx
68            .transpose(1, 2)
69            .map_err(e)?
70            .contiguous()
71            .map_err(e)?
72            .reshape((b, s, d))
73            .map_err(e)?;
74        self.out_proj.forward(&ctx).map_err(e)
75    }
76
77    fn forward(&self, x: &Tensor, add_mask: &Tensor) -> Result<Tensor> {
78        let normed = self.norm1.forward(x).map_err(e)?;
79        let attn = self.self_attention(&normed, add_mask)?;
80        let x = (x + attn).map_err(e)?;
81        let normed2 = self.norm2.forward(&x).map_err(e)?;
82        let ff = self
83            .linear2
84            .forward(
85                &self
86                    .linear1
87                    .forward(&normed2)
88                    .map_err(e)?
89                    .relu()
90                    .map_err(e)?,
91            )
92            .map_err(e)?;
93        (x + ff).map_err(e)
94    }
95}
96
97/// The full decision model: encoder + head.
98pub struct DecisionModel {
99    encoder: ModernBert,
100    type_emb: Tensor, // [3, d]
101    head_layers: Vec<HeadLayer>,
102    scorer_norm: LayerNorm,
103    scorer_lin1: Linear,
104    scorer_lin2: Linear, // [1, d]
105    act_lin1: Linear,    // [256, d+4]
106    act_lin2: Linear,    // [n_act, 256]
107    device: Device,
108    hidden: usize,
109}
110
111impl DecisionModel {
112    /// Load a checkpoint from `model.safetensors` + the encoder config directory.
113    ///
114    /// * `weights_path` — path to `model.safetensors`.
115    /// * `encoder_config` — parsed `encoder/config.json`.
116    /// * `head_layers` — `cfg.head_layers`.
117    /// * `n_act` — `len(cfg.act_costs) + 1`.
118    pub fn load(
119        weights_path: &Path,
120        encoder_config: &serde_json::Value,
121        head_layers: usize,
122        n_act: usize,
123        device: Device,
124    ) -> Result<Self> {
125        let mbcfg = build_mb_config(encoder_config);
126        let hidden = mbcfg.hidden_size;
127
128        // Load weights, converting to f32 and remapping the encoder prefix `encoder.` -> `model.`
129        // so candle-transformers' ModernBert (which hardcodes the `model.` root) finds them.
130        let raw = candle_core::safetensors::load(weights_path, &device).map_err(e)?;
131        let mut map: HashMap<String, Tensor> = HashMap::with_capacity(raw.len());
132        for (k, t) in raw {
133            let t = t.to_dtype(DType::F32).map_err(e)?;
134            let nk = match k.strip_prefix("encoder.") {
135                Some(rest) => format!("model.{}", rest),
136                None => k,
137            };
138            map.insert(nk, t);
139        }
140        let vb = VarBuilder::from_tensors(map, DType::F32, &device);
141
142        let encoder = ModernBert::load(vb.clone(), &mbcfg).map_err(e)?;
143
144        let get = |name: &str| -> Result<Tensor> { vb.get_unchecked(name).map_err(e) };
145        let linear =
146            |w: &str, b: &str| -> Result<Linear> { Ok(Linear::new(get(w)?, Some(get(b)?))) };
147        let layernorm = |w: &str, b: &str| -> Result<LayerNorm> {
148            Ok(LayerNorm::new(get(w)?, get(b)?, LN_EPS))
149        };
150
151        let type_emb = get("type_emb.weight")?; // [3, d]
152
153        let n_heads = std::cmp::max(1, hidden / 64);
154        let head_dim = hidden / n_heads;
155        let mut layers = Vec::with_capacity(head_layers);
156        for i in 0..head_layers {
157            let p = format!("head.layers.{i}");
158            layers.push(HeadLayer {
159                in_proj: Linear::new(
160                    get(&format!("{p}.self_attn.in_proj_weight"))?,
161                    Some(get(&format!("{p}.self_attn.in_proj_bias"))?),
162                ),
163                out_proj: linear(
164                    &format!("{p}.self_attn.out_proj.weight"),
165                    &format!("{p}.self_attn.out_proj.bias"),
166                )?,
167                linear1: linear(&format!("{p}.linear1.weight"), &format!("{p}.linear1.bias"))?,
168                linear2: linear(&format!("{p}.linear2.weight"), &format!("{p}.linear2.bias"))?,
169                norm1: layernorm(&format!("{p}.norm1.weight"), &format!("{p}.norm1.bias"))?,
170                norm2: layernorm(&format!("{p}.norm2.weight"), &format!("{p}.norm2.bias"))?,
171                n_heads,
172                head_dim,
173            });
174        }
175
176        let scorer_norm = layernorm("scorer.0.weight", "scorer.0.bias")?;
177        let scorer_lin1 = linear("scorer.1.weight", "scorer.1.bias")?;
178        let scorer_lin2 = linear("scorer.3.weight", "scorer.3.bias")?;
179        let act_lin1 = linear("act_head.0.weight", "act_head.0.bias")?;
180        let act_lin2 = linear("act_head.2.weight", "act_head.2.bias")?;
181
182        // Guard n_act against the checkpoint's actual act_head output width.
183        let _ = n_act;
184
185        Ok(DecisionModel {
186            encoder,
187            type_emb,
188            head_layers: layers,
189            scorer_norm,
190            scorer_lin1,
191            scorer_lin2,
192            act_lin1,
193            act_lin2,
194            device,
195            hidden,
196        })
197    }
198
199    /// Encoder hidden size.
200    pub fn hidden_size(&self) -> usize {
201        self.hidden
202    }
203
204    /// Mean-pool the encoder's `last_hidden_state` over non-padding tokens.
205    /// `input_ids`/`attention` are padded rectangular batches. Returns `[n, hidden]`.
206    /// Mirrors `embed_fn_from_agent` in `laya/shortlist.py`.
207    pub fn embed(&self, input_ids: &[Vec<u32>], attention: &[Vec<u32>]) -> Result<Vec<Vec<f32>>> {
208        let dev = &self.device;
209        let n = input_ids.len();
210        let l = input_ids.first().map(|r| r.len()).unwrap_or(0);
211        if n == 0 || l == 0 {
212            return Ok(Vec::new());
213        }
214        let ids = Tensor::from_vec(
215            input_ids.iter().flatten().copied().collect::<Vec<u32>>(),
216            (n, l),
217            dev,
218        )
219        .map_err(e)?;
220        let attn = Tensor::from_vec(
221            attention
222                .iter()
223                .flatten()
224                .map(|&x| x as f32)
225                .collect::<Vec<f32>>(),
226            (n, l),
227            dev,
228        )
229        .map_err(e)?;
230        let hidden = self.encoder.forward(&ids, &attn).map_err(e)?; // [n, l, d]
231        let mask = attn.unsqueeze(2).map_err(e)?; // [n, l, 1]
232        let summed = hidden.broadcast_mul(&mask).map_err(e)?.sum(1).map_err(e)?; // [n, d]
233        let counts = mask
234            .sum(1)
235            .map_err(e)?
236            .clamp(1.0f32, f32::INFINITY)
237            .map_err(e)?; // [n, 1]
238        let pooled = summed.broadcast_div(&counts).map_err(e)?; // [n, d]
239        pooled.to_vec2().map_err(e)
240    }
241
242    /// Run one collated batch. Returns `(logits, act_logits)` as row-major host vectors:
243    /// `logits[row][0..kmax]` (masked options filled with -1e4) and `act_logits[row][0..n_act]`.
244    pub fn forward(&self, b: &CollatedBatch) -> Result<ForwardOutput> {
245        let dev = &self.device;
246        let n = b.input_ids.len();
247        let l = b.input_ids.first().map(|r| r.len()).unwrap_or(0);
248        let kmax = b.marker_pos.first().map(|r| r.len()).unwrap_or(0);
249        let d = self.hidden;
250
251        let flat_ids: Vec<u32> = b.input_ids.iter().flatten().copied().collect();
252        let input_ids = Tensor::from_vec(flat_ids, (n, l), dev).map_err(e)?;
253        let flat_att: Vec<f32> = b
254            .attention_mask
255            .iter()
256            .flatten()
257            .map(|&x| x as f32)
258            .collect();
259        let attn = Tensor::from_vec(flat_att, (n, l), dev).map_err(e)?;
260
261        // Encoder → [n, L, d]
262        let hidden = self.encoder.forward(&input_ids, &attn).map_err(e)?;
263
264        // + type embedding (broadcast over sequence)
265        let qtype: Vec<u32> = b.qtype.iter().map(|&x| x as u32).collect();
266        let qtype = Tensor::from_vec(qtype, (n,), dev).map_err(e)?;
267        let type_vec = self.type_emb.index_select(&qtype, 0).map_err(e)?; // [n, d]
268        let mut h = hidden
269            .broadcast_add(&type_vec.unsqueeze(1).map_err(e)?)
270            .map_err(e)?;
271
272        // Additive key-padding mask [n,1,1,L]: attended(1)→0, padding(0)→f32::MIN, i.e. (1-mask)*MIN.
273        let add_mask = attn.affine(-1.0, 1.0).map_err(e)?; // 1 - mask
274        let add_mask = (add_mask * f32::MIN as f64)
275            .map_err(e)?
276            .reshape((n, 1, 1, l))
277            .map_err(e)?;
278
279        for layer in &self.head_layers {
280            h = layer.forward(&h, &add_mask)?;
281        }
282
283        // Gather option marker positions → [n, kmax, d]
284        let flat_pos: Vec<u32> = b.marker_pos.iter().flatten().map(|&x| x as u32).collect();
285        let idx = Tensor::from_vec(flat_pos, (n, kmax), dev).map_err(e)?;
286        let idx3 = idx
287            .unsqueeze(2)
288            .map_err(e)?
289            .broadcast_as((n, kmax, d))
290            .map_err(e)?
291            .contiguous()
292            .map_err(e)?;
293        let m = h.gather(&idx3, 1).map_err(e)?; // [n, kmax, d]
294
295        // scorer → [n, kmax]
296        let s = self.scorer_norm.forward(&m).map_err(e)?;
297        let s = self.scorer_lin1.forward(&s).map_err(e)?;
298        let s = s.gelu_erf().map_err(e)?;
299        let s = self.scorer_lin2.forward(&s).map_err(e)?; // [n, kmax, 1]
300        let logits = s.squeeze(2).map_err(e)?; // [n, kmax]
301
302        // masked_fill(~marker_mask, -1e4): logits*maskf + (maskf-1)*1e4
303        let flat_maskf: Vec<f32> = b
304            .marker_mask
305            .iter()
306            .flatten()
307            .map(|&x| if x { 1.0 } else { 0.0 })
308            .collect();
309        let maskf = Tensor::from_vec(flat_maskf, (n, kmax), dev).map_err(e)?;
310        let logits = (logits.mul(&maskf).map_err(e)?
311            + maskf.affine(-MASK_FILL, MASK_FILL).map_err(e)?)
312        .map_err(e)?;
313        let logits_vec: Vec<Vec<f32>> = logits.to_vec2().map_err(e)?;
314
315        // Action-head features (computed on host from the masked logits + marker counts).
316        let pooled = h.i((.., 0, ..)).map_err(e)?.contiguous().map_err(e)?; // [n, d]
317        let feats = self.action_features(&logits_vec, &b.marker_mask);
318        let feats_t = Tensor::from_vec(
319            feats.iter().flatten().copied().collect::<Vec<f32>>(),
320            (n, 4),
321            dev,
322        )
323        .map_err(e)?;
324        let cat = Tensor::cat(&[pooled, feats_t], D::Minus1).map_err(e)?; // [n, d+4]
325        let a = self.act_lin1.forward(&cat).map_err(e)?;
326        let a = a.gelu_erf().map_err(e)?;
327        let act_logits = self.act_lin2.forward(&a).map_err(e)?; // [n, n_act]
328        let act_vec: Vec<Vec<f32>> = act_logits.to_vec2().map_err(e)?;
329
330        Ok((logits_vec, act_vec))
331    }
332
333    /// The four action-head input features per row, matching `DecisionModel.forward`:
334    /// `[top1, top1-top2, entropy, k/255]`.
335    fn action_features(&self, logits: &[Vec<f32>], marker_mask: &[Vec<bool>]) -> Vec<[f32; 4]> {
336        logits
337            .iter()
338            .zip(marker_mask.iter())
339            .map(|(row, mask)| {
340                // softmax over the full kmax row (masked entries ≈ 0)
341                let p = crate::common::softmax(&row.iter().map(|&v| v as f64).collect::<Vec<_>>());
342                let k = (mask.iter().filter(|&&m| m).count() as f64).max(2.0);
343                let ent: f64 = -p
344                    .iter()
345                    .map(|&v| v * v.clamp(1e-9, f64::INFINITY).ln())
346                    .sum::<f64>()
347                    / k.ln();
348                let mut sorted = p.clone();
349                sorted.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
350                let top1 = sorted.first().copied().unwrap_or(0.0);
351                let top2 = sorted.get(1).copied().unwrap_or(0.0);
352                [
353                    top1 as f32,
354                    (top1 - top2) as f32,
355                    ent as f32,
356                    (k / 255.0) as f32,
357                ]
358            })
359            .collect()
360    }
361}
362
363/// Build a candle ModernBERT config from an HF `config.json` (HF uses `norm_eps`, candle
364/// `layer_norm_eps`), with ModernBERT defaults for any missing key.
365fn build_mb_config(cfg: &serde_json::Value) -> MbConfig {
366    let u = |key: &str, default: usize| -> usize {
367        cfg.get(key)
368            .and_then(|v| v.as_u64())
369            .map(|v| v as usize)
370            .unwrap_or(default)
371    };
372    let f = |key: &str, default: f64| -> f64 {
373        cfg.get(key).and_then(|v| v.as_f64()).unwrap_or(default)
374    };
375    // RoPE thetas: transformers 5.x nests them under `rope_parameters.<section>.rope_theta`
376    // (full_attention = global layers, sliding_attention = local layers). ModernBERT-large uses
377    // 160000/10000; mmBERT uses 160000/160000. Fall back to the flat keys, then the defaults.
378    let rope_theta = |section: &str, flat_key: &str, default: f64| -> f64 {
379        cfg.get("rope_parameters")
380            .and_then(|rp| rp.get(section))
381            .and_then(|s| s.get("rope_theta"))
382            .and_then(|v| v.as_f64())
383            .or_else(|| cfg.get(flat_key).and_then(|v| v.as_f64()))
384            .unwrap_or(default)
385    };
386    MbConfig {
387        vocab_size: u("vocab_size", 50368),
388        hidden_size: u("hidden_size", 768),
389        num_hidden_layers: u("num_hidden_layers", 22),
390        num_attention_heads: u("num_attention_heads", 12),
391        intermediate_size: u("intermediate_size", 1152),
392        max_position_embeddings: u("max_position_embeddings", 8192),
393        layer_norm_eps: f("norm_eps", 1e-5),
394        pad_token_id: u("pad_token_id", 50283) as u32,
395        global_attn_every_n_layers: u("global_attn_every_n_layers", 3),
396        global_rope_theta: rope_theta("full_attention", "global_rope_theta", 160000.0),
397        local_attention: u("local_attention", 128),
398        local_rope_theta: rope_theta("sliding_attention", "local_rope_theta", 10000.0),
399        classifier_config: None,
400    }
401}