Skip to main content

zsfm_toto/infer/
mod.rs

1// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License.
2//
3// This product includes software developed at Datadog (https://www.datadoghq.com/)
4// Copyright 2026 Datadog, Inc.
5
6//! Toto-2 inference engine.
7//!
8//! Unit-scaling (μP) inference notes:
9//! * `uu.Linear` → `F.linear(x, w, b) / sqrt(fan_in)`
10//! * `uu.LinearReadout` → `F.linear(x, w, b) / fan_in`
11//! * `U.silu` → `F.silu(x) * 1.766782948` (unit-scaled)
12//! * τ-rule: `residual_split` is identity; `residual_add(h,skip,τ)` = `h*(τ/d) + skip*(1/d)` where `d=√(1+τ²)`
13//! * `PerDimScale(q, w)` = `q * softplus(w) / log(2)` — the 0.52103 unit-scaling factors cancel
14
15mod rope;
16
17use std::collections::HashMap;
18use std::sync::Mutex;
19use std::io::BufReader;
20use std::path::{Path, PathBuf};
21
22use anyhow::{bail, Context, Result};
23use candle_core::quantized::gguf_file;
24use candle_core::{DType, Device, Tensor, D};
25use candle_nn::ops;
26
27use rope::RopeCache;
28use zsfm_core::{json_bool, json_f64, json_usize};
29use zsfm_nn::{load_tensor, try_load_tensor};
30
31const SILU_SCALE: f64 = 1.766782948312328;
32
33// ---------------------------------------------------------------------------
34// Config
35// ---------------------------------------------------------------------------
36
37#[derive(Clone, Debug)]
38pub struct InferConfig {
39    d_model: usize,
40    num_layers: usize,
41    num_heads: usize,
42    num_groups: usize,
43    qk_dim: usize,
44    v_dim: usize,
45    patch_size: usize,
46    norm_eps: f64,
47    layer_group_size: usize,
48    num_variate_layers_per_group: usize,
49    variate_layer_first: bool,
50    use_xpos: bool,
51    residual_mult: f64,
52    residual_attn_ratio: f64,
53    /// Run forward pass in F64 to match PyTorch numerical precision.
54    /// Weights are cast to F64 at load time (~2× memory). Default: false.
55    compute_f64: bool,
56}
57
58impl Default for InferConfig {
59    /// Matches the defaults of the upstream Datadog/Toto-2.0 `config.json`.
60    fn default() -> Self {
61        InferConfig {
62            d_model: 2048,
63            num_layers: 48,
64            num_heads: 32,
65            num_groups: 32,
66            qk_dim: 64,
67            v_dim: 64,
68            patch_size: 32,
69            norm_eps: 5e-4,
70            layer_group_size: 48,
71            num_variate_layers_per_group: 1,
72            variate_layer_first: false,
73            use_xpos: true,
74            residual_mult: 0.75,
75            residual_attn_ratio: 5.136215466577748,
76            compute_f64: false,
77        }
78    }
79}
80
81impl InferConfig {
82    fn is_variate_layer(&self, idx: usize) -> bool {
83        if self.variate_layer_first {
84            idx % self.layer_group_size < self.num_variate_layers_per_group
85        } else {
86            idx % self.layer_group_size
87                >= self.layer_group_size - self.num_variate_layers_per_group
88        }
89    }
90    fn q_size(&self) -> usize { self.qk_dim * self.num_heads }
91    fn k_size(&self) -> usize { self.qk_dim * self.num_groups }
92    fn v_size(&self) -> usize { self.v_dim * self.num_groups }
93
94    // -- builder-style setters — chain from `InferConfig::default()` ---------
95
96    pub fn with_d_model(mut self, v: usize) -> Self { self.d_model = v; self }
97    pub fn with_num_layers(mut self, v: usize) -> Self { self.num_layers = v; self }
98    pub fn with_num_heads(mut self, v: usize) -> Self { self.num_heads = v; self }
99    pub fn with_num_groups(mut self, v: usize) -> Self { self.num_groups = v; self }
100    pub fn with_qk_dim(mut self, v: usize) -> Self { self.qk_dim = v; self }
101    pub fn with_v_dim(mut self, v: usize) -> Self { self.v_dim = v; self }
102    pub fn with_patch_size(mut self, v: usize) -> Self { self.patch_size = v; self }
103    pub fn with_norm_eps(mut self, v: f64) -> Self { self.norm_eps = v; self }
104    pub fn with_layer_group_size(mut self, v: usize) -> Self { self.layer_group_size = v; self }
105    pub fn with_num_variate_layers_per_group(mut self, v: usize) -> Self {
106        self.num_variate_layers_per_group = v;
107        self
108    }
109    pub fn with_variate_layer_first(mut self, v: bool) -> Self { self.variate_layer_first = v; self }
110    pub fn with_use_xpos(mut self, v: bool) -> Self { self.use_xpos = v; self }
111    pub fn with_residual_mult(mut self, v: f64) -> Self { self.residual_mult = v; self }
112    pub fn with_residual_attn_ratio(mut self, v: f64) -> Self { self.residual_attn_ratio = v; self }
113
114    /// Run the forward pass in F64 (double precision) to match PyTorch numerical
115    /// accuracy. Uses ~2× memory. Default: F32.
116    pub fn with_compute_f64(mut self, v: bool) -> Self { self.compute_f64 = v; self }
117
118    // -- read-only accessors needed outside this crate ------------------------
119
120    pub fn patch_size(&self) -> usize { self.patch_size }
121    pub fn compute_f64(&self) -> bool { self.compute_f64 }
122
123    /// Overlay every recognised field from a parsed `config.json` onto
124    /// `InferConfig::default()`, using the same field names as the upstream
125    /// Datadog/Toto-2.0 config. Missing/unrecognised fields keep their default.
126    /// Shared by the CLI and (later) the Python bindings so the field-by-field
127    /// `.as_u64().unwrap_or(...)` boilerplate lives in exactly one place.
128    pub fn from_json_value(v: &serde_json::Value) -> Self {
129        Self::default()
130            .with_d_model(json_usize(v, "d_model", 2048))
131            .with_num_layers(json_usize(v, "num_layers", 48))
132            .with_num_heads(json_usize(v, "num_heads", 32))
133            .with_num_groups(json_usize(v, "num_groups", 32))
134            .with_qk_dim(json_usize(v, "qk_dim", 64))
135            .with_v_dim(json_usize(v, "v_dim", 64))
136            .with_patch_size(json_usize(v, "patch_size", 32))
137            .with_norm_eps(json_f64(v, "norm_eps", 5e-4))
138            .with_layer_group_size(json_usize(v, "layer_group_size", 48))
139            .with_num_variate_layers_per_group(json_usize(v, "num_variate_layers_per_group", 1))
140            .with_variate_layer_first(json_bool(v, "variate_layer_first", false))
141            .with_use_xpos(json_bool(v, "use_xpos", true))
142            .with_residual_mult(json_f64(v, "residual_mult", 0.75))
143            .with_residual_attn_ratio(json_f64(v, "residual_attn_ratio", 5.136215466577748))
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Builder
149// ---------------------------------------------------------------------------
150
151/// Fluent constructor for [`TotoModel`]: point it at a GGUF file, optionally
152/// layer on a parsed `config.json` and/or explicit [`InferConfig`] overrides,
153/// then call [`build`](TotoModelBuilder::build).
154///
155/// ```no_run
156/// use zsfm_toto::infer::TotoModel;
157///
158/// # fn main() -> anyhow::Result<()> {
159/// let model = TotoModel::builder("toto.gguf")
160///     .config_json(&serde_json::json!({ "d_model": 2048, "num_layers": 48 }))
161///     .with_compute_f64(true)
162///     .build()?;
163/// # Ok(()) }
164/// ```
165pub struct TotoModelBuilder {
166    gguf_path: PathBuf,
167    config: InferConfig,
168}
169
170impl TotoModelBuilder {
171    fn new(gguf_path: impl Into<PathBuf>) -> Self {
172        Self { gguf_path: gguf_path.into(), config: InferConfig::default() }
173    }
174
175    /// Replace the whole config, e.g. one built by hand via
176    /// `InferConfig::default().with_d_model(...).with_num_layers(...)`.
177    pub fn config(mut self, config: InferConfig) -> Self {
178        self.config = config;
179        self
180    }
181
182    /// Populate the config from a parsed HuggingFace `config.json`.
183    pub fn config_json(mut self, v: &serde_json::Value) -> Self {
184        self.config = InferConfig::from_json_value(v);
185        self
186    }
187
188    /// Run the forward pass in F64 (double precision) to match PyTorch numerical
189    /// accuracy. Uses ~2× memory. Default: F32.
190    pub fn with_compute_f64(mut self, v: bool) -> Self {
191        self.config = self.config.with_compute_f64(v);
192        self
193    }
194
195    pub fn build(self) -> Result<TotoModel> {
196        TotoModel::load(&self.gguf_path, self.config)
197    }
198}
199
200// ---------------------------------------------------------------------------
201// Weights
202// ---------------------------------------------------------------------------
203
204struct ResidualMlpWeights {
205    l1_w: Tensor, l1_b: Tensor,
206    l2_w: Tensor, l2_b: Tensor,
207    skip_w: Tensor, skip_b: Tensor,
208    tau: f64,
209    is_output: bool, // true → use LinearReadout scale (1/fan_in) for l2 and skip
210}
211
212struct BlockWeights {
213    attn_qkv_w: Tensor,
214    attn_qkv_b: Option<Tensor>,
215    attn_out_w: Tensor,
216    attn_out_b: Option<Tensor>,
217    attn_pds: Option<Tensor>, // [qk_dim] raw param
218    attn_tau: f64,
219    ffn_up_w: Tensor,
220    ffn_down_w: Tensor,
221    mlp_tau: f64,
222}
223
224// ---------------------------------------------------------------------------
225// Model
226// ---------------------------------------------------------------------------
227
228pub struct TotoModel {
229    device: Device,
230    pub config: InferConfig,
231    rope: RopeCache,
232    patch_proj: ResidualMlpWeights,
233    blocks: Vec<BlockWeights>,
234    output_head: ResidualMlpWeights,
235    causal_mask_cache: Mutex<HashMap<usize, Tensor>>,
236}
237
238// ---------------------------------------------------------------------------
239// GGUF loading helpers (free functions to avoid multiple &mut borrows)
240// ---------------------------------------------------------------------------
241
242/// Compute attn_tau and mlp_tau for each layer using the transformer residual scaling rule.
243///
244/// This reproduces `uu.transformer_residual_scaling_rule` + the per-layer buffer assignment
245/// from `SelfAttentionTransformerLayer.__init__` in the Python model.
246fn compute_taus(num_layers: usize, residual_mult: f64, residual_attn_ratio: f64) -> (Vec<f64>, Vec<f64>) {
247    let total_depth = 2 * num_layers;
248    let alpha_mlp = residual_mult * (2.0 / (1.0 + residual_attn_ratio.powi(2))).sqrt();
249    let alpha_attn = residual_attn_ratio * alpha_mlp;
250
251    let tau = |index: usize| -> f64 {
252        let n_attn = (index + 1) / 2;
253        let n_mlp = index / 2;
254        let num = if index % 2 == 0 { alpha_attn } else { alpha_mlp };
255        let den = (total_depth as f64 / 2.0
256            + n_attn as f64 * alpha_attn.powi(2)
257            + n_mlp as f64 * alpha_mlp.powi(2))
258        .sqrt();
259        num / den
260    };
261
262    (0..num_layers).map(|i| (tau(2 * i), tau(2 * i + 1))).unzip()
263}
264
265impl TotoModel {
266    /// Start building a [`TotoModel`] — see [`TotoModelBuilder`].
267    pub fn builder(gguf_path: impl Into<PathBuf>) -> TotoModelBuilder {
268        TotoModelBuilder::new(gguf_path)
269    }
270
271    pub fn load(gguf_path: &Path, config: InferConfig) -> Result<Self> {
272        let device = Device::Cpu;
273        let dtype = if config.compute_f64 { DType::F64 } else { DType::F32 };
274        let file = std::fs::File::open(gguf_path)
275            .with_context(|| format!("open {}", gguf_path.display()))?;
276        let mut reader = BufReader::with_capacity(zsfm_gguf::READ_BUF_CAPACITY, file);
277        let content = gguf_file::Content::read(&mut reader)
278            .context("parse GGUF header")?;
279
280        macro_rules! ld {
281            ($name:expr) => { load_tensor(&content, &mut reader, $name, &device, dtype) };
282        }
283        macro_rules! tld {
284            ($name:expr) => { try_load_tensor(&content, &mut reader, $name, &device, dtype) };
285        }
286
287        // --- patch_proj ---
288        let patch_proj = ResidualMlpWeights {
289            l1_w: ld!("patch_proj.linear1.weight")?,
290            l1_b: ld!("patch_proj.linear1.bias")?,
291            l2_w: ld!("patch_proj.linear2.weight")?,
292            l2_b: ld!("patch_proj.linear2.bias")?,
293            skip_w: ld!("patch_proj.skip_proj.weight")?,
294            skip_b: ld!("patch_proj.skip_proj.bias")?,
295            tau: 1.0,
296            is_output: false,
297        };
298
299        // Tau values are deterministic given the config — compute rather than load
300        // (avoids candle's inability to dequantize 0-dim tensors)
301        let (attn_taus, mlp_taus) = compute_taus(
302            config.num_layers,
303            config.residual_mult,
304            config.residual_attn_ratio,
305        );
306
307        // --- transformer blocks ---
308        let mut blocks = Vec::with_capacity(config.num_layers);
309        for n in 0..config.num_layers {
310            blocks.push(BlockWeights {
311                attn_qkv_w: ld!(&format!("blk.{n}.attn_qkv.weight"))?,
312                attn_qkv_b: tld!(&format!("blk.{n}.attn_qkv.bias"))?,
313                attn_out_w: ld!(&format!("blk.{n}.attn_output.weight"))?,
314                attn_out_b: tld!(&format!("blk.{n}.attn_output.bias"))?,
315                attn_pds:   tld!(&format!("blk.{n}.attn_pds.weight"))?,
316                attn_tau: attn_taus[n],
317                ffn_up_w:   ld!(&format!("blk.{n}.ffn_up.weight"))?,
318                ffn_down_w: {
319                    let w = ld!(&format!("blk.{n}.ffn_down.weight"))?;
320                    // Q8_0 converter stores this weight transposed so candle's innermost
321                    // dim (d_model) is block-aligned. Detect by dim(0) == d_ff and flip back.
322                    if w.dim(0)? != config.d_model {
323                        w.t()?.contiguous()?
324                    } else {
325                        w
326                    }
327                },
328                mlp_tau: mlp_taus[n],
329            });
330        }
331
332        // --- output_head ---
333        let output_head = ResidualMlpWeights {
334            l1_w: ld!("output_head.linear1.weight")?,
335            l1_b: ld!("output_head.linear1.bias")?,
336            l2_w: ld!("output_head.linear2.weight")?,
337            l2_b: ld!("output_head.linear2.bias")?,
338            skip_w: ld!("output_head.skip_proj.weight")?,
339            skip_b: ld!("output_head.skip_proj.bias")?,
340            tau: 1.0,
341            is_output: true,
342        };
343
344        let rope = RopeCache::new(config.qk_dim, 8192);
345        Ok(Self { device, config, rope, patch_proj, blocks, output_head, causal_mask_cache: Mutex::new(HashMap::new()) })
346    }
347
348    // -----------------------------------------------------------------------
349    // Public API
350    // -----------------------------------------------------------------------
351
352    /// Forecast from context.
353    ///
354    /// `target[n_var][ctx_len]` — observed values.
355    /// `mask[n_var][ctx_len]` — true for valid observations.
356    /// Returns `quantiles[9][n_var][prediction_length]`.
357    ///
358    /// Toto is a full-context model: the prediction region is appended to the
359    /// context as masked (all-zero) patches before the forward pass.  The model
360    /// attends over the full sequence and outputs quantiles for the future region.
361    pub fn forecast(
362        &self,
363        target: &[Vec<f32>],
364        mask: &[Vec<bool>],
365        prediction_length: usize,
366    ) -> Result<Vec<Vec<Vec<f32>>>> {
367        let n_var = target.len();
368        let ctx_len = target[0].len();
369        let patch_size = self.config.patch_size;
370
371        if ctx_len % patch_size != 0 {
372            bail!("ctx_len ({ctx_len}) must be divisible by patch_size ({patch_size})");
373        }
374        let ctx_patches = ctx_len / patch_size;
375        // Number of forecast patches needed (ceiling division)
376        let fcst_patches = (prediction_length + patch_size - 1) / patch_size;
377        let total_patches = ctx_patches + fcst_patches;
378
379        // Causal patched std scaler on context only
380        let mut locs = Vec::with_capacity(n_var);
381        let mut scales = Vec::with_capacity(n_var);
382        for v in 0..n_var {
383            let (loc, scale) = causal_patched_std_scaler(&target[v], &mask[v], patch_size);
384            locs.push(loc);
385            scales.push(scale);
386        }
387
388        // Build full input: context patches (observed) + forecast patches (masked zeros)
389        // Shape: [1, n_var, total_patches, 2*patch_size]
390        let mut patch_data = vec![0.0f32; n_var * total_patches * 2 * patch_size];
391        for v in 0..n_var {
392            // Context patches
393            for p in 0..ctx_patches {
394                let base = v * total_patches * 2 * patch_size + p * 2 * patch_size;
395                for i in 0..patch_size {
396                    let t = p * patch_size + i;
397                    let obs = mask[v][t];
398                    let val = if obs {
399                        (((target[v][t] - locs[v][t]) / scales[v][t]) as f64).asinh() as f32
400                    } else {
401                        0.0
402                    };
403                    patch_data[base + i] = val;
404                    patch_data[base + patch_size + i] = if obs { 0.0 } else { 1.0 };
405                }
406            }
407            // Forecast patches: all zeros, not observed (mask channel = 1)
408            for p in ctx_patches..total_patches {
409                let base = v * total_patches * 2 * patch_size + p * 2 * patch_size;
410                for i in 0..patch_size {
411                    patch_data[base + i] = 0.0;
412                    patch_data[base + patch_size + i] = 1.0; // not observed
413                }
414            }
415        }
416
417        let dtype = if self.config.compute_f64 { DType::F64 } else { DType::F32 };
418        let x = Tensor::from_vec(
419            patch_data,
420            (1usize, n_var, total_patches, 2 * patch_size),
421            &self.device,
422        )?.to_dtype(dtype)?;
423
424        // patch_proj (InputResidualMLP)
425        let x = self.forward_residual_mlp(&x, &self.patch_proj)?;
426
427        // transformer
428        let x = self.forward_transformer(x, n_var, total_patches)?;
429
430        // output_head (OutputResidualMLP)
431        let x = self.forward_residual_mlp(&x, &self.output_head)?;
432        // x: [1, n_var, total_patches, patch_size * 9]
433
434        // Next-patch predictor: output at position i predicts patch i+1.
435        // Python: x_out[..., -(block+1):-1] where block=fcst_patches
436        // = positions [ctx_patches-1, ctx_patches+fcst_patches-1)
437        let x = x.narrow(2, ctx_patches - 1, fcst_patches)?;
438        // x: [1, n_var, fcst_patches, patch_size * 9]
439        let fcst_steps = fcst_patches * patch_size;
440
441        // Unflatten → [1, n_var, fcst_patches, patch_size, 9]
442        let out = x.reshape((1usize, n_var, fcst_patches, patch_size, 9))?;
443        // Permute → [9, 1, n_var, fcst_patches, patch_size]
444        let out = out.permute([4, 0, 1, 2, 3])?.contiguous()?;
445        // Flatten → [9, n_var, fcst_steps]
446        let out = out.reshape((9usize, n_var, fcst_steps))?;
447
448        // Trim to exactly prediction_length (if fcst_steps > prediction_length)
449        let out = if fcst_steps > prediction_length {
450            out.narrow(2, 0, prediction_length)?
451        } else {
452            out
453        };
454        // Cast back to F32 for output (harmless if already F32)
455        let out_data = out.to_dtype(DType::F32)?.to_vec3::<f32>()?; // [9][n_var][pred_len]
456
457        // Denormalise: sinh(quantile) * scale + loc (using final-patch stats)
458        let loc_final: Vec<f32> = (0..n_var).map(|v| locs[v][ctx_len - 1]).collect();
459        let scale_final: Vec<f32> = (0..n_var).map(|v| scales[v][ctx_len - 1]).collect();
460
461        let mut result = vec![vec![vec![0.0f32; prediction_length]; n_var]; 9];
462        for q in 0..9 {
463            for v in 0..n_var {
464                for t in 0..prediction_length {
465                    let raw = out_data[q][v][t] as f64;
466                    result[q][v][t] = (raw.sinh() as f32) * scale_final[v] + loc_final[v];
467                }
468            }
469        }
470        Ok(result)
471    }
472
473    // -----------------------------------------------------------------------
474    // ResidualMLP forward
475    // -----------------------------------------------------------------------
476
477    fn forward_residual_mlp(&self, x: &Tensor, w: &ResidualMlpWeights) -> Result<Tensor> {
478        // residual_split at inference = identity; both branches see x
479        let h = uu_linear(x, &w.l1_w, Some(&w.l1_b))?;
480        let h = uu_silu(&h)?;
481
482        let h = if w.is_output {
483            uu_linear_readout(&h, &w.l2_w, Some(&w.l2_b))?
484        } else {
485            uu_linear(&h, &w.l2_w, Some(&w.l2_b))?
486        };
487
488        let skip = if w.is_output {
489            uu_linear_readout(x, &w.skip_w, Some(&w.skip_b))?
490        } else {
491            uu_linear(x, &w.skip_w, Some(&w.skip_b))?
492        };
493
494        residual_add(&h, &skip, w.tau)
495    }
496
497    // -----------------------------------------------------------------------
498    // Transformer
499    // -----------------------------------------------------------------------
500
501    fn forward_transformer(&self, mut x: Tensor, n_var: usize, num_patches: usize) -> Result<Tensor> {
502        for (idx, blk) in self.blocks.iter().enumerate() {
503            x = if self.config.is_variate_layer(idx) {
504                self.forward_variate_layer(x, blk, n_var, num_patches)?
505            } else {
506                self.forward_time_layer(x, blk, n_var, num_patches)?
507            };
508        }
509        rms_norm(&x, self.config.norm_eps)
510    }
511
512    fn forward_time_layer(
513        &self,
514        x: Tensor,
515        blk: &BlockWeights,
516        n_var: usize,
517        num_patches: usize,
518    ) -> Result<Tensor> {
519        let cfg = &self.config;
520        // [1, n_var, num_patches, d_model] → [n_var, num_patches, d_model]
521        let state = x.reshape((n_var, num_patches, cfg.d_model))?;
522
523        let normed = rms_norm(&state, cfg.norm_eps)?;
524        let seq_ids: Vec<u32> = (0..num_patches as u32).collect();
525        let attn_out = self.forward_attention(&normed, blk, &seq_ids, /*is_variate=*/false)?;
526        let state = residual_add(&attn_out, &state, blk.attn_tau)?;
527
528        let normed = rms_norm(&state, cfg.norm_eps)?;
529        let ffn_out = self.forward_ffn(&normed, blk)?;
530        let state = residual_add(&ffn_out, &state, blk.mlp_tau)?;
531
532        // Restore [1, n_var, num_patches, d_model]
533        Ok(state.reshape((1usize, n_var, num_patches, cfg.d_model))?)
534    }
535
536    fn forward_variate_layer(
537        &self,
538        x: Tensor,
539        blk: &BlockWeights,
540        n_var: usize,
541        num_patches: usize,
542    ) -> Result<Tensor> {
543        let cfg = &self.config;
544        // [1, n_var, num_patches, d] → [num_patches, n_var, d]
545        let state = x.permute([0, 2, 1, 3])?.contiguous()?.reshape((num_patches, n_var, cfg.d_model))?;
546
547        let normed = rms_norm(&state, cfg.norm_eps)?;
548        let attn_out = self.forward_attention(&normed, blk, &[], /*is_variate=*/true)?;
549        let state = residual_add(&attn_out, &state, blk.attn_tau)?;
550
551        let normed = rms_norm(&state, cfg.norm_eps)?;
552        let ffn_out = self.forward_ffn(&normed, blk)?;
553        let state = residual_add(&ffn_out, &state, blk.mlp_tau)?;
554
555        // [num_patches, n_var, d] → [1, n_var, num_patches, d]
556        Ok(state
557            .reshape((1usize, num_patches, n_var, cfg.d_model))?
558            .permute([0, 2, 1, 3])?
559            .contiguous()?)
560    }
561
562    // -----------------------------------------------------------------------
563    // Self-attention
564    // -----------------------------------------------------------------------
565
566    fn forward_attention(
567        &self,
568        state: &Tensor,
569        blk: &BlockWeights,
570        seq_ids: &[u32],
571        is_variate: bool,
572    ) -> Result<Tensor> {
573        let cfg = &self.config;
574        let (batch, seq, _d) = state.dims3()?;
575
576        // Fused QKV — narrow then contiguous (narrow on last-dim creates non-contiguous views)
577        let qkv = uu_linear(state, &blk.attn_qkv_w, blk.attn_qkv_b.as_ref())?;
578        let q = qkv.narrow(D::Minus1, 0, cfg.q_size())?.contiguous()?;
579        let k = qkv.narrow(D::Minus1, cfg.q_size(), cfg.k_size())?.contiguous()?;
580        let v = qkv.narrow(D::Minus1, cfg.q_size() + cfg.k_size(), cfg.v_size())?.contiguous()?;
581
582        // [batch, seq, heads, head_dim] → [batch, heads, seq, head_dim]
583        let q = q.reshape((batch, seq, cfg.num_heads, cfg.qk_dim))?.permute([0, 2, 1, 3])?.contiguous()?;
584        let k = k.reshape((batch, seq, cfg.num_groups, cfg.qk_dim))?.permute([0, 2, 1, 3])?.contiguous()?;
585        let v = v.reshape((batch, seq, cfg.num_groups, cfg.v_dim))?.permute([0, 2, 1, 3])?.contiguous()?;
586
587        // PerDimScale on Q
588        let q = match blk.attn_pds.as_ref() {
589            Some(pds_w) => apply_per_dim_scale(&q, pds_w)?,
590            None => q,
591        };
592
593        // xPos-RoPE (time layers only)
594        let (q, k) = if !is_variate && !seq_ids.is_empty() && cfg.use_xpos {
595            let q = self.rope.apply(&q, seq_ids, 1.0, &self.device)?;
596            let k = self.rope.apply(&k, seq_ids, -1.0, &self.device)?;
597            (q, k)
598        } else {
599            (q, k)
600        };
601
602        // MuP scale: 1/qk_dim
603        let scale = 1.0 / cfg.qk_dim as f64;
604        let scores = (q.matmul(&k.transpose(D::Minus1, D::Minus2)?)? * scale)?;
605
606        // Causal mask for time layers
607        let scores = if !is_variate {
608            self.apply_causal_mask(scores, seq)?
609        } else {
610            scores
611        };
612
613        let attn = ops::softmax_last_dim(&scores)?;
614        let out = attn.matmul(&v)?;
615        // [batch, heads, seq, v_dim] → [batch, seq, heads*v_dim]
616        let out = out.permute([0, 2, 1, 3])?.contiguous()?.reshape((batch, seq, cfg.num_heads * cfg.v_dim))?;
617
618        uu_linear(&out, &blk.attn_out_w, blk.attn_out_b.as_ref())
619    }
620
621    // -----------------------------------------------------------------------
622    // SwiGLU FFN
623    // -----------------------------------------------------------------------
624
625    fn forward_ffn(&self, x: &Tensor, blk: &BlockWeights) -> Result<Tensor> {
626        let fc1_out = uu_linear(x, &blk.ffn_up_w, None)?;
627        let half = fc1_out.dim(D::Minus1)? / 2;
628        let gate = fc1_out.narrow(D::Minus1, 0, half)?;
629        let val = fc1_out.narrow(D::Minus1, half, half)?;
630        // Standard F.silu (not unit-scaled) as in the Python FFN
631        let activated = (gate * ops::silu(&val)?)?;
632        uu_linear(&activated, &blk.ffn_down_w, None)
633    }
634}
635
636// ---------------------------------------------------------------------------
637// Unit-scaling ops
638// ---------------------------------------------------------------------------
639
640/// Unit-scaled linear for arbitrary batch shapes.
641///
642/// Flattens leading dims to 2D for matmul (candle doesn't broadcast 2D weights against nD inputs),
643/// then restores the original batch shape.
644fn uu_linear(x: &Tensor, w: &Tensor, b: Option<&Tensor>) -> Result<Tensor> {
645    linear_with_scale(x, w, b, 1.0 / (w.dim(1)? as f64).sqrt())
646}
647
648fn uu_linear_readout(x: &Tensor, w: &Tensor, b: Option<&Tensor>) -> Result<Tensor> {
649    linear_with_scale(x, w, b, 1.0 / w.dim(1)? as f64)
650}
651
652fn linear_with_scale(x: &Tensor, w: &Tensor, b: Option<&Tensor>, scale: f64) -> Result<Tensor> {
653    let shape = x.dims().to_vec();
654    let d_in = *shape.last().unwrap();
655    let batch: usize = shape[..shape.len() - 1].iter().product();
656    let d_out = w.dim(0)?;
657
658    let x_flat = x.reshape((batch, d_in))?;
659    let out_flat = x_flat.matmul(&w.t()?)?; // [batch, d_out]
660
661    let mut out_shape = shape[..shape.len() - 1].to_vec();
662    out_shape.push(d_out);
663    let out = out_flat.reshape(out_shape)?;
664    let out = if let Some(b) = b { out.broadcast_add(b)? } else { out };
665    Ok((out * scale)?)
666}
667
668fn uu_silu(x: &Tensor) -> Result<Tensor> {
669    Ok((ops::silu(x)? * SILU_SCALE)?)
670}
671
672fn rms_norm(x: &Tensor, eps: f64) -> Result<Tensor> {
673    zsfm_nn::rms_norm(x, None, eps)
674}
675
676fn residual_add(h: &Tensor, skip: &Tensor, tau: f64) -> Result<Tensor> {
677    let denom = (1.0 + tau * tau).sqrt();
678    Ok(((h * (tau / denom))? + (skip * (1.0 / denom))?)?)
679}
680
681fn apply_per_dim_scale(q: &Tensor, pds_w: &Tensor) -> Result<Tensor> {
682    // Python: q * F.softplus(pds_w) / log(2)
683    // The 0.52103 unit-scaling factors cancel between uu.softplus and per_dim_scale.
684    let sp = (pds_w.exp()? + 1.0)?.log()?; // F.softplus
685    let log2 = std::f64::consts::LN_2;
686    let r = (sp / log2)?;
687    Ok(q.broadcast_mul(&r)?)
688}
689
690impl TotoModel {
691    fn apply_causal_mask(&self, scores: Tensor, seq: usize) -> Result<Tensor> {
692        let mut cache = self.causal_mask_cache.lock().unwrap();
693        if !cache.contains_key(&seq) {
694            let mut mask_data = vec![0.0f32; seq * seq];
695            for i in 0..seq {
696                for j in (i + 1)..seq {
697                    mask_data[i * seq + j] = f32::NEG_INFINITY;
698                }
699            }
700            let dtype = if self.config.compute_f64 { DType::F64 } else { DType::F32 };
701            let mask = Tensor::from_vec(mask_data, (seq, seq), &self.device)?.to_dtype(dtype)?;
702            cache.insert(seq, mask);
703        }
704        Ok(scores.broadcast_add(&cache[&seq])?)
705    }
706}
707
708// ---------------------------------------------------------------------------
709// Causal patched std scaler
710// ---------------------------------------------------------------------------
711
712fn causal_patched_std_scaler(
713    data: &[f32],
714    mask: &[bool],
715    patch_size: usize,
716) -> (Vec<f32>, Vec<f32>) {
717    let n = data.len();
718    let num_patches = n / patch_size;
719
720    let mut cum_count = 0.0f64;
721    let mut m1 = 0.0f64;
722    let mut m2 = 0.0f64;
723    let correction = 1.0f64;
724    let minimum_scale = 1e-6f64;
725
726    let mut patch_loc = vec![0.0f32; num_patches];
727    let mut patch_scale = vec![1e-6f32; num_patches];
728
729    for p in 0..num_patches {
730        for i in 0..patch_size {
731            let t = p * patch_size + i;
732            if mask[t] {
733                let x = data[t] as f64;
734                cum_count += 1.0;
735                let prev_m1 = m1;
736                m1 += (x - m1) / cum_count;
737                m2 += (x - prev_m1) * (x - m1);
738            }
739        }
740        patch_loc[p] = m1 as f32;
741        let denom = (cum_count - correction).max(1.0);
742        patch_scale[p] = (m2 / denom).sqrt().max(minimum_scale) as f32;
743    }
744
745    let mut loc = vec![0.0f32; n];
746    let mut scale = vec![1e-6f32; n];
747    for p in 0..num_patches {
748        for i in 0..patch_size {
749            let t = p * patch_size + i;
750            loc[t] = patch_loc[p];
751            scale[t] = patch_scale[p];
752        }
753    }
754    (loc, scale)
755}
756
757// ---------------------------------------------------------------------------
758// zsfm-core::Forecaster
759// ---------------------------------------------------------------------------
760
761impl zsfm_core::Forecaster for TotoModel {
762    type Config = InferConfig;
763
764    fn load(gguf_path: &Path, config: InferConfig) -> Result<Self> {
765        TotoModel::load(gguf_path, config)
766    }
767
768    fn forecast(
769        &self,
770        context: &[Vec<f32>],
771        mask: &[Vec<bool>],
772        horizon: usize,
773    ) -> Result<zsfm_core::QuantileMatrix> {
774        TotoModel::forecast(self, context, mask, horizon)
775    }
776}