Skip to main content

zsfm/
moirai2.rs

1use std::path::PathBuf;
2
3use crate::common::DtypeArg;
4use anyhow::Context;
5use clap::Subcommand;
6
7use zsfm_core::{ForecastOutput, VariateForecast};
8use zsfm_gguf::GGMLType;
9use zsfm_moirai2::config::Moirai2Config;
10use zsfm_moirai2::convert::{convert, ConvertOptions};
11
12#[derive(Subcommand)]
13pub enum Command {
14    /// Upload source + GGUF files to HuggingFace Hub.
15    Upload {
16        #[arg(short, long, default_value = "amaye15/moirai2-gguf")]
17        repo: String,
18        #[arg(long, env = "HF_TOKEN")]
19        token: String,
20        /// Directory tree to upload. Defaults to the current directory.
21        #[arg(long, default_value = ".")]
22        root: PathBuf,
23    },
24    /// Download Moirai-2.0-R-small from HuggingFace and convert to GGUF.
25    ///
26    /// The first conversion downloads the model and writes a canonical F32 GGUF to
27    /// `<model_dir>/<owner>__<name>/model-f32.gguf`, deleting the (large) downloaded
28    /// weight files afterward. Later conversions (any --dtype) recast from that
29    /// cached F32 GGUF instead of re-downloading — pass --redownload to force a
30    /// fresh download anyway (e.g. the repo was updated).
31    Convert {
32        #[arg(short, long, default_value = "Salesforce/moirai-2.0-R-small")]
33        model: String,
34        #[arg(short, long, default_value = "gguf/moirai2-f32.gguf")]
35        output: PathBuf,
36        #[arg(long, default_value = "f32")]
37        dtype: DtypeArg,
38        #[arg(long, default_value = "models")]
39        model_dir: PathBuf,
40        #[arg(long, env = "HF_TOKEN")]
41        token: Option<String>,
42        /// Force a fresh download even if a cached F32 GGUF already exists.
43        #[arg(long)]
44        redownload: bool,
45    },
46    /// Print all tensor names in a local checkpoint file (safetensors, PyTorch
47    /// pickle, GGUF, or npy/npz — format auto-detected).
48    InspectTensors { path: PathBuf },
49    /// Run Moirai-2.0-R-small forecasting from a GGUF file. Point-forecast only,
50    /// channel-independent across variates. No --config flag: the architecture is fixed
51    /// (Moirai2Config::default()), unlike chronos/flowstate/toto/ttm, which vary by
52    /// checkpoint and need config.json.
53    ///
54    /// Reads a JSON request from stdin.
55    ///
56    /// Univariate batch:
57    ///   {"context": [[t0, t1, ...], [t0, t1, ...]], "horizon": N}
58    ///
59    /// Multivariate batch (channel-independent per variate):
60    ///   {"context": [[[v0_t0, v0_t1, ...], [v1_t0, v1_t1, ...]], ...], "horizon": N}
61    ///
62    /// Univariate output has "point"/"quantiles" in each choice.
63    /// Multivariate output has "variates": [{"point":..., "quantiles":...}, ...].
64    Infer {
65        #[arg(short, long, default_value = "gguf/moirai2-f32.gguf")]
66        gguf: PathBuf,
67    },
68
69    /// Delete cached model files for this model.
70    ///
71    /// Removes the canonical F32 GGUF and config cache at
72    /// `<model_dir>/<owner>__<name>/` created by `convert`. By default only
73    /// the cache is removed; pass `--output <path>` to also delete a
74    /// converted GGUF file (e.g. `gguf/...`).
75    Delete {
76        /// HuggingFace repo id (must match the `convert` --model you used).
77        #[arg(short, long, default_value = "Salesforce/moirai-2.0-R-small")]
78        model: String,
79        /// Directory where the cache was written (must match `convert` --model-dir).
80        #[arg(long, default_value = "models")]
81        model_dir: PathBuf,
82        /// Also delete this output GGUF file if it exists.
83        #[arg(short, long)]
84        output: Option<PathBuf>,
85    },
86}
87
88pub async fn run(command: Command) -> anyhow::Result<()> {
89    match command {
90        Command::Delete {
91            model,
92            model_dir,
93            output,
94        } => {
95            let canonical = zsfm_hub::canonical_gguf_path(&model_dir, &model);
96            crate::common::delete_cached_model(&canonical, output.as_deref())?;
97        }
98        Command::Upload { repo, token, root } => {
99            zsfm_hub::upload_repo(&repo, &token, &root).await?;
100        }
101
102        Command::Convert {
103            model,
104            output,
105            dtype,
106            model_dir,
107            token,
108            redownload,
109        } => {
110            let canonical = zsfm_hub::canonical_gguf_path(&model_dir, &model);
111            if crate::common::try_recast_from_cache(&canonical, &output, dtype.into(), redownload)?
112            {
113                return Ok(());
114            }
115
116            eprintln!("Downloading {model} into {} …", model_dir.display());
117            let files = zsfm_hub::download_model(&model, token.as_deref(), &model_dir)
118                .await
119                .context("download failed")?;
120
121            let config = Moirai2Config::default();
122            let f32_opts = ConvertOptions {
123                output_dtype: GGMLType::F32,
124            };
125            convert(&files.safetensors_shards, &config, &f32_opts, &canonical)?;
126            eprintln!("Wrote canonical F32 GGUF to {} …", canonical.display());
127            files.cleanup_weights();
128
129            zsfm_checkpoint::recast(&canonical, &output, dtype.into())?;
130            eprintln!("Wrote {}", output.display());
131        }
132
133        Command::InspectTensors { path } => crate::common::inspect_tensors(&path)?,
134
135        Command::Infer { gguf } => {
136            run_infer_burn(&gguf).await?;
137        }
138    }
139
140    Ok(())
141}
142
143async fn run_infer_burn(gguf: &std::path::PathBuf) -> anyhow::Result<()> {
144    use zsfm_moirai2::infer::BurnMoirai2Model;
145
146    let buf = zsfm_core::read_stdin_limited()?;
147    let req: serde_json::Value = serde_json::from_str(&buf).context("parse JSON input")?;
148    let contexts = zsfm_core::parse_mv_contexts(req["context"].clone())?;
149    let horizon = zsfm_core::parse_horizon(&req)?;
150    let config = Moirai2Config::default();
151
152    eprintln!("Loading Burn model from {} …", gguf.display());
153    let model = BurnMoirai2Model::load(gguf, config).context("load model")?;
154
155    let mut fc_outputs: Vec<ForecastOutput> = Vec::new();
156    let mut total_ctx: usize = 0;
157
158    for raw_variates in &contexts {
159        let n_var = raw_variates.len();
160        anyhow::ensure!(n_var > 0, "each context must have at least one variate");
161
162        if n_var == 1 {
163            let ctx = &raw_variates[0];
164            anyhow::ensure!(!ctx.is_empty(), "context series must not be empty");
165            total_ctx += ctx.len();
166            let point = model.forecast(ctx, horizon).context("forecast")?;
167            fc_outputs.push(ForecastOutput::Univariate {
168                point,
169                quantiles: Default::default(),
170            });
171        } else {
172            let mut var_forecasts: Vec<VariateForecast> = Vec::with_capacity(n_var);
173            for (vi, ctx) in raw_variates.iter().enumerate() {
174                anyhow::ensure!(!ctx.is_empty(), "variate {vi} context must not be empty");
175                total_ctx += ctx.len();
176                let point = model
177                    .forecast(ctx, horizon)
178                    .with_context(|| format!("forecast variate {vi}"))?;
179                var_forecasts.push(VariateForecast {
180                    point,
181                    quantiles: Default::default(),
182                });
183            }
184            fc_outputs.push(ForecastOutput::Multivariate {
185                variates: var_forecasts,
186            });
187        }
188    }
189    println!(
190        "{}",
191        zsfm_core::forecast_response_json("moirai2", total_ctx, horizon, fc_outputs)?
192    );
193    Ok(())
194}