Skip to main content

zsfm_chronos/infer/
mod.rs

1//! Chronos-2 inference engine.
2//!
3//! Architecture: encoder-only with alternating TimeSelfAttention + GroupSelfAttention + FFN.
4//! Key differences from standard T5:
5//! * Standard Llama RoPE (`rotate_half = [-x[half:], x[:half]]`)
6//! * T5-style RMSNorm (no bias, no mean subtraction)
7//! * No attention scale (scale=1.0 in MHA)
8//! * GroupSelfAttention for batch=1 reduces to position-wise v → o projection
9
10mod rope;
11
12use std::io::{BufReader, Read, Seek};
13use std::path::{Path, PathBuf};
14
15use anyhow::{bail, Context, Result};
16use candle_core::quantized::gguf_file;
17use candle_core::{DType, Device, Tensor, D};
18
19use rope::RopeCache;
20use zsfm_nn::{linear, load_weight};
21
22use crate::config::Chronos2Config;
23
24// ---------------------------------------------------------------------------
25// Config
26// ---------------------------------------------------------------------------
27
28#[derive(Clone, Debug)]
29pub struct InferConfig {
30    d_model: usize,
31    d_kv: usize,
32    d_ff: usize,
33    num_layers: usize,
34    num_heads: usize,
35    layer_norm_eps: f64,
36    rope_theta: f64,
37    patch_size: usize,
38    patch_stride: usize,
39    context_length: usize,
40    quantiles: Vec<f32>,
41    use_reg_token: bool,
42    use_arcsinh: bool,
43    time_encoding_scale: usize,
44    dense_act_fn: String,
45}
46
47/// `Chronos2Config` already resolved every default (via serde) or failed to parse if a
48/// genuinely required field was missing, so this mapping is infallible and needs no further
49/// defaulting of its own.
50impl From<&Chronos2Config> for InferConfig {
51    fn from(c2: &Chronos2Config) -> Self {
52        let cc = &c2.chronos_config;
53        InferConfig {
54            d_model: c2.d_model as usize,
55            d_kv: c2.d_kv as usize,
56            d_ff: c2.d_ff as usize,
57            num_layers: c2.num_layers as usize,
58            num_heads: c2.num_heads as usize,
59            layer_norm_eps: c2.layer_norm_epsilon,
60            rope_theta: c2.rope_theta,
61            patch_size: cc.input_patch_size as usize,
62            patch_stride: cc.input_patch_stride as usize,
63            context_length: cc.context_length as usize,
64            quantiles: cc.quantiles.clone(),
65            use_reg_token: cc.use_reg_token,
66            use_arcsinh: cc.use_arcsinh,
67            time_encoding_scale: c2.time_encoding_scale() as usize,
68            dense_act_fn: c2.dense_act_fn().to_string(),
69        }
70    }
71}
72
73impl InferConfig {
74    fn inner_dim(&self) -> usize { self.num_heads * self.d_kv }
75
76    pub fn patch_size(&self) -> usize { self.patch_size }
77    pub fn patch_stride(&self) -> usize { self.patch_stride }
78    pub fn context_length(&self) -> usize { self.context_length }
79    pub fn quantiles(&self) -> &[f32] { &self.quantiles }
80}
81
82// ---------------------------------------------------------------------------
83// Builder
84// ---------------------------------------------------------------------------
85
86/// Fluent constructor for [`ChronosModel`]: point it at a GGUF file and a config (from a parsed
87/// `config.json` via [`config_from`](ChronosModelBuilder::config_from)), then call
88/// [`build`](ChronosModelBuilder::build).
89///
90/// ```no_run
91/// use zsfm_chronos::{Chronos2Config, ChronosModel};
92///
93/// # fn main() -> anyhow::Result<()> {
94/// let c2 = Chronos2Config::from_json(&std::fs::read_to_string("config.json")?)?;
95/// let model = ChronosModel::builder("chronos.gguf").config_from(&c2).build()?;
96/// # Ok(()) }
97/// ```
98pub struct ChronosModelBuilder {
99    gguf_path: PathBuf,
100    config: Option<InferConfig>,
101}
102
103impl ChronosModelBuilder {
104    fn new(gguf_path: impl Into<PathBuf>) -> Self {
105        Self { gguf_path: gguf_path.into(), config: None }
106    }
107
108    pub fn config(mut self, config: InferConfig) -> Self {
109        self.config = Some(config);
110        self
111    }
112
113    pub fn config_from(mut self, c2: &Chronos2Config) -> Self {
114        self.config = Some(InferConfig::from(c2));
115        self
116    }
117
118    pub fn build(self) -> Result<ChronosModel> {
119        let config = self
120            .config
121            .context("ChronosModelBuilder: no config set — call .config(...) or .config_from(...)")?;
122        ChronosModel::load(&self.gguf_path, config)
123    }
124}
125
126// ---------------------------------------------------------------------------
127// Weight structs
128// ---------------------------------------------------------------------------
129
130struct ResidualBlockWeights {
131    /// hidden_layer weight: (h_dim, in_dim) in PyTorch order
132    hidden_w: Tensor,
133    hidden_b: Tensor,
134    /// output_layer weight: (out_dim, h_dim)
135    output_w: Tensor,
136    output_b: Tensor,
137    /// residual_layer weight: (out_dim, in_dim)
138    skip_w: Tensor,
139    skip_b: Tensor,
140}
141
142struct AttnWeights {
143    qkv_w: Tensor, // fused [3*inner_dim, d_model]
144    o_w: Tensor,
145    norm_w: Tensor,
146}
147
148struct FfnWeights {
149    wi_w: Tensor,
150    wo_w: Tensor,
151    norm_w: Tensor,
152}
153
154struct BlockWeights {
155    time_attn: AttnWeights,
156    group_attn: AttnWeights,
157    ffn: FfnWeights,
158}
159
160// ---------------------------------------------------------------------------
161// Model
162// ---------------------------------------------------------------------------
163
164pub struct ChronosModel {
165    device: Device,
166    pub config: InferConfig,
167    rope: RopeCache,
168    /// Token embedding (for [PAD]/[REG] special tokens).
169    token_embd: Tensor,
170    input_patch: ResidualBlockWeights,
171    blocks: Vec<BlockWeights>,
172    enc_norm: Tensor,
173    output_patch: ResidualBlockWeights,
174}
175
176// ---------------------------------------------------------------------------
177// GGUF loading helpers
178// ---------------------------------------------------------------------------
179
180fn load_tensor(
181    content: &gguf_file::Content,
182    reader: &mut (impl Read + Seek),
183    name: &str,
184    device: &Device,
185) -> Result<Tensor> {
186    zsfm_nn::load_tensor(content, reader, name, device, DType::F32)
187}
188
189fn load_residual_block(
190    content: &gguf_file::Content,
191    reader: &mut (impl Read + Seek),
192    prefix: &str,
193    d_out_h: usize,  // hidden_layer d_out = h_dim
194    d_out: usize,    // output_layer / skip d_out
195    device: &Device,
196) -> Result<ResidualBlockWeights> {
197    // hidden_layer: (h_dim, in_dim)
198    let hidden_w = load_weight(content, reader, &format!("{prefix}.hidden.weight"), d_out_h, device)?;
199    let hidden_b = load_tensor(content, reader, &format!("{prefix}.hidden.bias"), device)?;
200    // output_layer: (out_dim, h_dim)
201    let output_w = load_weight(content, reader, &format!("{prefix}.output.weight"), d_out, device)?;
202    let output_b = load_tensor(content, reader, &format!("{prefix}.output.bias"), device)?;
203    // residual_layer: (out_dim, in_dim)
204    let skip_w = load_weight(content, reader, &format!("{prefix}.skip.weight"), d_out, device)?;
205    let skip_b = load_tensor(content, reader, &format!("{prefix}.skip.bias"), device)?;
206    Ok(ResidualBlockWeights { hidden_w, hidden_b, output_w, output_b, skip_w, skip_b })
207}
208
209fn load_attn(
210    content: &gguf_file::Content,
211    reader: &mut (impl Read + Seek),
212    blk: usize,
213    kind: &str,
214    d_model: usize,
215    inner_dim: usize,
216    device: &Device,
217) -> Result<AttnWeights> {
218    let p = |s: &str| format!("blk.{blk}.{kind}.{s}");
219    let norm_name = format!("blk.{blk}.{kind}_norm.weight");
220    let q_w = load_weight(content, reader, &p("q.weight"), inner_dim, device)?;
221    let k_w = load_weight(content, reader, &p("k.weight"), inner_dim, device)?;
222    let v_w = load_weight(content, reader, &p("v.weight"), inner_dim, device)?;
223    let qkv_w = Tensor::cat(&[&q_w, &k_w, &v_w], 0)
224        .with_context(|| format!("qkv cat blk.{blk}.{kind}"))?;
225    Ok(AttnWeights {
226        qkv_w,
227        o_w:    load_weight(content, reader, &p("o.weight"), d_model, device)?,
228        norm_w: load_tensor(content, reader, &norm_name, device)?,
229    })
230}
231
232impl ChronosModel {
233    /// Start building a [`ChronosModel`] — see [`ChronosModelBuilder`].
234    pub fn builder(gguf_path: impl Into<PathBuf>) -> ChronosModelBuilder {
235        ChronosModelBuilder::new(gguf_path)
236    }
237
238    pub fn load(gguf_path: &Path, config: InferConfig) -> Result<Self> {
239        let device = Device::Cpu;
240        let file = std::fs::File::open(gguf_path)
241            .with_context(|| format!("open {}", gguf_path.display()))?;
242        let mut reader = BufReader::with_capacity(zsfm_gguf::READ_BUF_CAPACITY, file);
243        let content = gguf_file::Content::read(&mut reader).context("parse GGUF header")?;
244
245        let d = config.d_model;
246        let ff = config.d_ff;
247        let id = config.inner_dim();
248        let ps = config.patch_size;
249        let nq = config.quantiles.len();
250
251        let token_embd = load_tensor(&content, &mut reader, "token_embd.weight", &device)?;
252
253        // input_patch_embedding: in=3*ps, h=d_ff, out=d_model
254        let input_patch = load_residual_block(
255            &content, &mut reader, "input_patch", ff, d, &device,
256        )?;
257
258        // Encoder blocks
259        let mut blocks = Vec::with_capacity(config.num_layers);
260        for n in 0..config.num_layers {
261            let time_attn = load_attn(&content, &mut reader, n, "time_attn", d, id, &device)?;
262            let group_attn = load_attn(&content, &mut reader, n, "group_attn", d, id, &device)?;
263            let ffn = FfnWeights {
264                wi_w: load_weight(&content, &mut reader, &format!("blk.{n}.ffn.wi.weight"), ff, &device)?,
265                wo_w: load_weight(&content, &mut reader, &format!("blk.{n}.ffn.wo.weight"), d, &device)?,
266                norm_w: load_tensor(&content, &mut reader, &format!("blk.{n}.ffn_norm.weight"), &device)?,
267            };
268            blocks.push(BlockWeights { time_attn, group_attn, ffn });
269        }
270
271        let enc_norm = load_tensor(&content, &mut reader, "enc_norm.weight", &device)?;
272
273        // output_patch_embedding: in=d_model, h=d_ff, out=num_quantiles*patch_size
274        let out_d = nq * ps;
275        let output_patch = load_residual_block(
276            &content, &mut reader, "output_patch", ff, out_d, &device,
277        )?;
278
279        let rope = RopeCache::new(config.d_kv, 8192, config.rope_theta, &device)?;
280
281        Ok(Self { device, config, rope, token_embd, input_patch, blocks, enc_norm, output_patch })
282    }
283
284    // -----------------------------------------------------------------------
285    // Public API
286    // -----------------------------------------------------------------------
287
288    /// Forecast univariate time series.
289    ///
290    /// `context` is a slice of observed values. `prediction_length` is the
291    /// number of future timesteps. Returns `quantiles[num_q][prediction_length]`.
292    pub fn forecast(&self, context: &[f32], prediction_length: usize) -> Result<Vec<Vec<f32>>> {
293        let cfg = &self.config;
294        let ps = cfg.patch_size;
295        let stride = cfg.patch_stride;
296        let n_quantiles = cfg.quantiles.len();
297
298        // Number of output patches needed (ceiling division)
299        let n_out_patches = (prediction_length + ps - 1) / ps;
300
301        // --- 1. InstanceNorm ---
302        let (normalized, loc, scale) = instance_norm(context, cfg.use_arcsinh);
303
304        // --- 2. Patch context ---
305        let padded = pad_for_patching(&normalized, ps);
306        let n_ctx_patches = (padded.len() - ps) / stride + 1;
307
308        // --- 3. Build input features for context patches [n_ctx_patches, 3*ps] ---
309        let ctx_features = build_patch_features(
310            &padded, n_ctx_patches, ps, stride,
311            -((n_ctx_patches * ps) as f32), 0.0,  // time enc: [-n*ps, ..., -1] / scale
312            cfg.time_encoding_scale as f32,
313            true, // observed (mask = 1)
314        );
315
316        // --- 4. Build input features for future patches [n_out_patches, 3*ps] ---
317        let fut_features = build_patch_features_future(
318            n_out_patches, ps,
319            0.0,  // start time index
320            cfg.time_encoding_scale as f32,
321        );
322
323        // --- 5. Input patch embedding ---
324        let ctx_tensor = Tensor::from_vec(ctx_features, (n_ctx_patches, 3 * ps), &self.device)?;
325        let fut_tensor = Tensor::from_vec(fut_features, (n_out_patches, 3 * ps), &self.device)?;
326
327        let ctx_embeds = self.forward_residual_block(&ctx_tensor, &self.input_patch)?;
328        let fut_embeds = self.forward_residual_block(&fut_tensor, &self.input_patch)?;
329
330        // --- 6. Optionally add [REG] token between context and future patches ---
331        let seq = if cfg.use_reg_token {
332            // [REG] token id is 1 (stored in shared embedding)
333            let reg_embed = self.token_embd.narrow(0, 1, 1)?; // [1, d_model]
334            Tensor::cat(&[&ctx_embeds, &reg_embed, &fut_embeds], 0)?
335        } else {
336            Tensor::cat(&[&ctx_embeds, &fut_embeds], 0)?
337        };
338        // seq: [total_seq, d_model]
339        let total_seq = seq.dim(0)?;
340
341        // Add batch dimension: [1, total_seq, d_model]
342        let hidden = seq.unsqueeze(0)?;
343
344        // --- 7. Encoder forward pass ---
345        let hidden = self.forward_encoder(hidden, total_seq)?;
346        // hidden: [1, total_seq, d_model]
347
348        // --- 8. Slice last n_out_patches hidden states ---
349        let forecast_embeds = hidden.narrow(1, total_seq - n_out_patches, n_out_patches)?;
350        // [1, n_out_patches, d_model]
351
352        // --- 9. Output patch embedding ---
353        let forecast_embeds = forecast_embeds.squeeze(0)?; // [n_out_patches, d_model]
354        let quantile_raw = self.forward_residual_block(&forecast_embeds, &self.output_patch)?;
355        // [n_out_patches, num_quantiles * patch_size]
356
357        // Reshape to [num_quantiles, n_out_patches * patch_size]
358        let total_out = n_out_patches * ps;
359        let quantile_raw = quantile_raw.reshape((n_out_patches, n_quantiles, ps))?;
360        let quantile_raw = quantile_raw.permute([1, 0, 2])?.contiguous()?;
361        let quantile_raw = quantile_raw.reshape((n_quantiles, total_out))?;
362
363        // Trim to prediction_length
364        let quantile_raw = if total_out > prediction_length {
365            quantile_raw.narrow(1, 0, prediction_length)?
366        } else {
367            quantile_raw
368        };
369
370        // Unscale
371        let data = quantile_raw.to_vec2::<f32>()?;
372        let mut result = vec![vec![0.0f32; prediction_length]; n_quantiles];
373        for q in 0..n_quantiles {
374            for t in 0..prediction_length {
375                let v = data[q][t] as f64;
376                let v = if cfg.use_arcsinh { v.sinh() } else { v };
377                result[q][t] = (v as f32) * scale + loc;
378            }
379        }
380        Ok(result)
381    }
382
383    // -----------------------------------------------------------------------
384    // Encoder
385    // -----------------------------------------------------------------------
386
387    fn forward_encoder(&self, mut x: Tensor, seq_len: usize) -> Result<Tensor> {
388        for blk in &self.blocks {
389            x = self.forward_block(x, blk, seq_len)?;
390        }
391        // Final layer norm: [1, seq_len, d_model]
392        apply_t5_rms_norm(&x, &self.enc_norm, self.config.layer_norm_eps)
393    }
394
395    fn forward_block(
396        &self,
397        x: Tensor,
398        blk: &BlockWeights,
399        seq_len: usize,
400    ) -> Result<Tensor> {
401        // TimeSelfAttention: pre-norm + add residual
402        let normed = apply_t5_rms_norm(&x, &blk.time_attn.norm_w, self.config.layer_norm_eps)?;
403        let attn_out = self.forward_time_attn(&normed, &blk.time_attn, seq_len)?;
404        let x = (&x + &attn_out)?;
405
406        // GroupSelfAttention (batch=1 simplified: v + o projection)
407        let normed = apply_t5_rms_norm(&x, &blk.group_attn.norm_w, self.config.layer_norm_eps)?;
408        let grp_out = self.forward_group_attn_univariate(&normed, &blk.group_attn)?;
409        let x = (&x + &grp_out)?;
410
411        // FeedForward: pre-norm + add residual
412        let normed = apply_t5_rms_norm(&x, &blk.ffn.norm_w, self.config.layer_norm_eps)?;
413        let ffn_out = self.forward_ffn(&normed, &blk.ffn)?;
414        Ok((&x + &ffn_out)?)
415    }
416
417    // -----------------------------------------------------------------------
418    // TimeSelfAttention (bidirectional, with RoPE, scale=1.0)
419    // -----------------------------------------------------------------------
420
421    fn forward_time_attn(
422        &self,
423        x: &Tensor,        // [1, seq_len, d_model]
424        w: &AttnWeights,
425        seq_len: usize,
426    ) -> Result<Tensor> {
427        let id = self.config.inner_dim();
428        let nh = self.config.num_heads;
429        let dkv = self.config.d_kv;
430
431        // Project q, k, v: single fused matmul → [1, seq_len, 3*inner_dim]
432        let qkv = linear(x, &w.qkv_w, None)?;
433        let q = qkv.narrow(D::Minus1, 0, id)?;
434        let k = qkv.narrow(D::Minus1, id, id)?;
435        let v = qkv.narrow(D::Minus1, 2 * id, id)?;
436
437        // Reshape: [1, n_heads, seq_len, d_kv]
438        let q = q.reshape((1, seq_len, nh, dkv))?.permute([0, 2, 1, 3])?.contiguous()?;
439        let k = k.reshape((1, seq_len, nh, dkv))?.permute([0, 2, 1, 3])?.contiguous()?;
440        let v = v.reshape((1, seq_len, nh, dkv))?.permute([0, 2, 1, 3])?.contiguous()?;
441
442        let q = self.rope.apply(&q, seq_len)?;
443        let k = self.rope.apply(&k, seq_len)?;
444
445        // Attention: scores = q @ k^T (no scale)
446        let scores = q.matmul(&k.transpose(D::Minus1, D::Minus2)?)?;
447        // Bidirectional: no mask — all zeros (all positions valid)
448        let attn = candle_nn::ops::softmax_last_dim(&scores)?;
449
450        // Context: attn @ v → [1, n_heads, seq_len, d_kv]
451        let out = attn.matmul(&v)?;
452
453        // [1, n_heads, seq_len, d_kv] → [1, seq_len, inner_dim]
454        let out = out.permute([0, 2, 1, 3])?.contiguous()?.reshape((1, seq_len, id))?;
455
456        // Output projection
457        linear(&out, &w.o_w, None)
458    }
459
460    // -----------------------------------------------------------------------
461    // GroupSelfAttention — batch=1 (univariate) simplification.
462    //
463    // For a single time series the "batch" dimension has size 1, so each
464    // timestep attends only to itself in the batch axis.  The attention
465    // weights trivially become 1.0, reducing to:
466    //   output = o_proj(v_proj(x))
467    // -----------------------------------------------------------------------
468
469    fn forward_group_attn_univariate(&self, x: &Tensor, w: &AttnWeights) -> Result<Tensor> {
470        // x: [1, seq_len, d_model]  (after pre-norm, batch=1)
471        // For univariate batch=1, group attention reduces to v_proj → o_proj.
472        // Extract v slice from the fused qkv weight.
473        let id = self.config.inner_dim();
474        let qkv = linear(x, &w.qkv_w, None)?;
475        let v = qkv.narrow(D::Minus1, 2 * id, id)?;
476        linear(&v, &w.o_w, None)
477    }
478
479    // -----------------------------------------------------------------------
480    // FeedForward: x → wi → act → wo  (no gating, default relu)
481    // -----------------------------------------------------------------------
482
483    fn forward_ffn(&self, x: &Tensor, w: &FfnWeights) -> Result<Tensor> {
484        let h = linear(x, &w.wi_w, None)?;
485        let h = apply_act(&h, &self.config.dense_act_fn)?;
486        linear(&h, &w.wo_w, None)
487    }
488
489    // -----------------------------------------------------------------------
490    // ResidualBlock: output_layer(act(hidden_layer(x))) + residual_layer(x)
491    // -----------------------------------------------------------------------
492
493    fn forward_residual_block(&self, x: &Tensor, w: &ResidualBlockWeights) -> Result<Tensor> {
494        let h = linear(x, &w.hidden_w, Some(&w.hidden_b))?;
495        let h = apply_act(&h, &self.config.dense_act_fn)?;
496        let out = linear(&h, &w.output_w, Some(&w.output_b))?;
497        let skip = linear(x, &w.skip_w, Some(&w.skip_b))?;
498        Ok((&out + &skip)?)
499    }
500}
501
502// ---------------------------------------------------------------------------
503// Primitive ops
504// ---------------------------------------------------------------------------
505
506/// T5-style RMSNorm: weight * x * rsqrt(mean(x²) + eps).
507/// No bias, no mean subtraction.
508fn apply_t5_rms_norm(x: &Tensor, weight: &Tensor, eps: f64) -> Result<Tensor> {
509    zsfm_nn::rms_norm(x, Some(weight), eps)
510}
511
512/// Apply dense activation function by name (supports "relu" and "gelu").
513fn apply_act(x: &Tensor, name: &str) -> Result<Tensor> {
514    match name {
515        "relu" => Ok(x.relu()?),
516        "gelu" => Ok(x.gelu_erf()?),       // standard erf-based gelu
517        "gelu_new" | "gelu_pytorch_tanh" => Ok(x.gelu()?), // tanh approx
518        "silu" | "swish" => Ok(candle_nn::ops::silu(x)?),
519        other => bail!("unsupported activation function: {other}"),
520    }
521}
522
523// ---------------------------------------------------------------------------
524// InstanceNorm (standardization)
525// ---------------------------------------------------------------------------
526
527/// Subtract mean, divide by std, optionally apply arcsinh.
528/// Returns (normalized, loc, scale) where loc and scale are scalars.
529fn instance_norm(x: &[f32], use_arcsinh: bool) -> (Vec<f32>, f32, f32) {
530    // Ignore NaN — for our use case input is fully observed.
531    let n = x.len() as f64;
532    let loc = x.iter().map(|&v| v as f64).sum::<f64>() / n;
533    let var = x.iter().map(|&v| (v as f64 - loc).powi(2)).sum::<f64>() / n;
534    let scale = (var.sqrt() as f32).max(1e-5);
535
536    let mut out: Vec<f32> = x.iter().map(|&v| (v as f64 - loc) as f32 / scale).collect();
537    if use_arcsinh {
538        for v in &mut out {
539            *v = (*v as f64).asinh() as f32;
540        }
541    }
542    (out, loc as f32, scale)
543}
544
545// ---------------------------------------------------------------------------
546// Patching
547// ---------------------------------------------------------------------------
548
549/// Pad the left side of the series with NaN so its length is divisible by patch_size.
550fn pad_for_patching(x: &[f32], patch_size: usize) -> Vec<f32> {
551    let rem = x.len() % patch_size;
552    if rem == 0 {
553        x.to_vec()
554    } else {
555        let pad_len = patch_size - rem;
556        let mut out = vec![f32::NAN; pad_len];
557        out.extend_from_slice(x);
558        out
559    }
560}
561
562/// Build concatenated [time_enc | values | mask] patches for context.
563///
564/// * time_enc: sequential indices from `time_start` advancing by 1 per timestep,
565///   divided by `time_scale`.
566/// * values: patch values (NaN positions → 0.0).
567/// * mask: 1.0 where value is finite, 0.0 otherwise.
568fn build_patch_features(
569    padded: &[f32],
570    n_patches: usize,
571    patch_size: usize,
572    stride: usize,
573    time_start: f32,
574    _time_end: f32,
575    time_scale: f32,
576    observed: bool,
577) -> Vec<f32> {
578    let feature_dim = 3 * patch_size;
579    let mut out = vec![0.0f32; n_patches * feature_dim];
580
581    for p in 0..n_patches {
582        let offset = p * stride;
583        let base = p * feature_dim;
584
585        for i in 0..patch_size {
586            let t = offset + i;
587            let global_t = time_start + t as f32;
588
589            // time encoding
590            out[base + i] = global_t / time_scale;
591
592            // value
593            let v = padded.get(t).copied().unwrap_or(0.0);
594            let is_obs = observed && v.is_finite();
595            out[base + patch_size + i] = if is_obs { v } else { 0.0 };
596
597            // mask: 1 if observed
598            out[base + 2 * patch_size + i] = if is_obs { 1.0 } else { 0.0 };
599        }
600    }
601    out
602}
603
604/// Build concatenated [time_enc | zeros | zeros] patches for future (no known values).
605fn build_patch_features_future(
606    n_patches: usize,
607    patch_size: usize,
608    time_start: f32,
609    time_scale: f32,
610) -> Vec<f32> {
611    let feature_dim = 3 * patch_size;
612    let mut out = vec![0.0f32; n_patches * feature_dim];
613
614    for p in 0..n_patches {
615        let offset = p * patch_size;
616        let base = p * feature_dim;
617        for i in 0..patch_size {
618            let global_t = time_start + (offset + i) as f32;
619            out[base + i] = global_t / time_scale;
620            // values and mask remain 0.0
621        }
622    }
623    out
624}
625
626// ---------------------------------------------------------------------------
627// zsfm-core::Forecaster
628// ---------------------------------------------------------------------------
629
630impl zsfm_core::Forecaster for ChronosModel {
631    type Config = InferConfig;
632
633    fn load(gguf_path: &Path, config: InferConfig) -> Result<Self> {
634        ChronosModel::load(gguf_path, config)
635    }
636
637    /// Chronos-2's `forecast()` is univariate-only; `context`/`mask` must carry exactly one
638    /// variate (mask is currently unused — Chronos always treats the series as fully observed).
639    fn forecast(
640        &self,
641        context: &[Vec<f32>],
642        _mask: &[Vec<bool>],
643        horizon: usize,
644    ) -> Result<zsfm_core::QuantileMatrix> {
645        anyhow::ensure!(context.len() == 1, "ChronosModel only supports univariate forecasting (1 variate)");
646        let qmat = ChronosModel::forecast(self, &context[0], horizon)?; // [n_q][horizon]
647        Ok(qmat.into_iter().map(|row| vec![row]).collect())
648    }
649}