Skip to main content

zsfm/
moirai.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_moirai::config::MoiraiConfig;
10use zsfm_moirai::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/moirai-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-1.0-R-large 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-1.0-R-large")]
33        model: String,
34        #[arg(short, long, default_value = "gguf/moirai-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 forecasting from a GGUF file. Point-forecast only, channel-independent
50    /// across variates. No --config flag: the architecture is fixed (MoiraiConfig::default()),
51    /// unlike chronos/flowstate/toto/ttm, which vary by checkpoint and need config.json.
52    ///
53    /// Reads a JSON request from stdin.
54    ///
55    /// Univariate batch:
56    ///   {"context": [[t0, t1, ...], [t0, t1, ...]], "horizon": N}
57    ///
58    /// Multivariate batch (channel-independent per variate):
59    ///   {"context": [[[v0_t0, v0_t1, ...], [v1_t0, v1_t1, ...]], ...], "horizon": N}
60    ///
61    /// Univariate output has "point"/"quantiles" in each choice.
62    /// Multivariate output has "variates": [{"point":..., "quantiles":...}, ...].
63    Infer {
64        #[arg(short, long, default_value = "gguf/moirai-f32.gguf")]
65        gguf: PathBuf,
66    },
67
68    /// Delete cached model files for this model.
69    ///
70    /// Removes the canonical F32 GGUF and config cache at
71    /// `<model_dir>/<owner>__<name>/` created by `convert`. By default only
72    /// the cache is removed; pass `--output <path>` to also delete a
73    /// converted GGUF file (e.g. `gguf/...`).
74    Delete {
75        /// HuggingFace repo id (must match the `convert` --model you used).
76        #[arg(short, long, default_value = "Salesforce/moirai-1.0-R-large")]
77        model: String,
78        /// Directory where the cache was written (must match `convert` --model-dir).
79        #[arg(long, default_value = "models")]
80        model_dir: PathBuf,
81        /// Also delete this output GGUF file if it exists.
82        #[arg(short, long)]
83        output: Option<PathBuf>,
84    },
85}
86
87pub async fn run(command: Command) -> anyhow::Result<()> {
88    match command {
89        Command::Delete {
90            model,
91            model_dir,
92            output,
93        } => {
94            let canonical = zsfm_hub::canonical_gguf_path(&model_dir, &model);
95            crate::common::delete_cached_model(&canonical, output.as_deref())?;
96        }
97        Command::Upload { repo, token, root } => {
98            zsfm_hub::upload_repo(&repo, &token, &root).await?;
99        }
100
101        Command::Convert {
102            model,
103            output,
104            dtype,
105            model_dir,
106            token,
107            redownload,
108        } => {
109            let canonical = zsfm_hub::canonical_gguf_path(&model_dir, &model);
110            if crate::common::try_recast_from_cache(&canonical, &output, dtype.into(), redownload)?
111            {
112                return Ok(());
113            }
114
115            eprintln!("Downloading {model} into {} …", model_dir.display());
116            let files = zsfm_hub::download_model(&model, token.as_deref(), &model_dir)
117                .await
118                .context("download failed")?;
119
120            let config = MoiraiConfig::default();
121            let f32_opts = ConvertOptions {
122                output_dtype: GGMLType::F32,
123            };
124            convert(&files.safetensors_shards, &config, &f32_opts, &canonical)?;
125            eprintln!("Wrote canonical F32 GGUF to {} …", canonical.display());
126            files.cleanup_weights();
127
128            zsfm_checkpoint::recast(&canonical, &output, dtype.into())?;
129            eprintln!("Wrote {}", output.display());
130        }
131
132        Command::InspectTensors { path } => crate::common::inspect_tensors(&path)?,
133
134        Command::Infer { gguf } => {
135            run_infer_burn(&gguf).await?;
136        }
137    }
138
139    Ok(())
140}
141
142async fn run_infer_burn(gguf: &std::path::PathBuf) -> anyhow::Result<()> {
143    use zsfm_moirai::infer::BurnMoiraiModel;
144
145    let buf = zsfm_core::read_stdin_limited()?;
146    let req: serde_json::Value = serde_json::from_str(&buf).context("parse JSON input")?;
147    let contexts = zsfm_core::parse_mv_contexts(req["context"].clone())?;
148    let horizon = zsfm_core::parse_horizon(&req)?;
149    let config = MoiraiConfig::default();
150
151    eprintln!("Loading Burn model from {} …", gguf.display());
152    let model = BurnMoiraiModel::load(gguf, config).context("load model")?;
153
154    let mut fc_outputs: Vec<ForecastOutput> = Vec::new();
155    let mut total_ctx: usize = 0;
156
157    for raw_variates in &contexts {
158        let n_var = raw_variates.len();
159        anyhow::ensure!(n_var > 0, "each context must have at least one variate");
160
161        if n_var == 1 {
162            let ctx = &raw_variates[0];
163            anyhow::ensure!(!ctx.is_empty(), "context series must not be empty");
164            total_ctx += ctx.len();
165            let point = model.forecast(ctx, horizon).context("forecast")?;
166            fc_outputs.push(ForecastOutput::Univariate {
167                point,
168                quantiles: Default::default(),
169            });
170        } else {
171            let mut var_forecasts: Vec<VariateForecast> = Vec::with_capacity(n_var);
172            for (vi, ctx) in raw_variates.iter().enumerate() {
173                anyhow::ensure!(!ctx.is_empty(), "variate {vi} context must not be empty");
174                total_ctx += ctx.len();
175                let point = model
176                    .forecast(ctx, horizon)
177                    .with_context(|| format!("forecast variate {vi}"))?;
178                var_forecasts.push(VariateForecast {
179                    point,
180                    quantiles: Default::default(),
181                });
182            }
183            fc_outputs.push(ForecastOutput::Multivariate {
184                variates: var_forecasts,
185            });
186        }
187    }
188    println!(
189        "{}",
190        zsfm_core::forecast_response_json("moirai", total_ctx, horizon, fc_outputs)?
191    );
192    Ok(())
193}