Skip to main content

steeldb/
models.rs

1//! **Where the weights come from.**
2//!
3//! A published crate cannot carry its models. crates.io caps a package at 10 MB and the full model set is
4//! ~388 MB, so the crate ships what genuinely fits and resolves the rest at runtime.
5//!
6//! The split is not arbitrary, and one half of it is a licensing constraint rather than a size one:
7//!
8//! | what | size | how it arrives |
9//! |---|---|---|
10//! | trained heads and the reasoning core | ~1.5 MB | **compiled into the crate** |
11//! | relation head | ~2 MB | **compiled into the crate** |
12//! | the `bert-tiny` embedding table | 15.2 MB | downloaded — too large to package |
13//! | our tagger / SPLADE / LoRA weights | 100s of MB | downloaded from our own model repo |
14//! | third-party models (needle3, model2vec, PP-OCR) | 100s of MB | fetched from **their** repos, never re-hosted |
15//!
16//! That last row is the important one. We trained the tagger, the facet heads and the LoRA, so we may
17//! redistribute them. needle3 belongs to Cactus, `bert-tiny` to Google, and the others to their authors —
18//! re-hosting someone else's weights under our name would be wrong regardless of whether it is convenient, so
19//! those are fetched from the repository that published them and the licence is recorded here.
20//!
21//! Nothing downloads without being asked: [`resolve`] searches locally first and returns
22//! [`ModelError::NotFound`] with the exact fetch instruction if it comes up empty.
23
24use std::path::PathBuf;
25
26/// The trained parameters small enough to travel with the crate.
27///
28/// These are the parts trained from scratch — the two-timescale core and the classification heads. The
29/// embedding table they sit on top of is 10x larger than everything else combined and is fetched separately.
30pub mod bundled {
31    /// HRM reasoning core + the typed-span and epistemic heads, without the embedding table.
32    ///
33    /// Empty until `tools/split_checkpoint` writes it; the constant exists so the resolution path is the same
34    /// whether or not a build has produced it.
35    pub const HEADS: &[u8] = include_bytes!("../assets/heads.safetensors");
36
37    /// True when this build actually carries head weights rather than a placeholder.
38    pub fn have_heads() -> bool {
39        HEADS.len() > 1024
40    }
41}
42
43/// Where a set of weights comes from, and what we are permitted to do with it.
44///
45/// An earlier version of this enum forbade mirroring third-party weights on principle. That was the wrong
46/// rule: every model this engine uses turns out to be permissively licensed (Apache-2.0 or MIT), so mirroring
47/// is allowed. The obligations that *do* bind are attribution, carrying the licence text, and stating
48/// modifications — none of which is satisfied by simply refusing to host a copy.
49///
50/// The more important protection is the pinned revision. A mirror without a pin still drifts when we refresh
51/// it; a pin without a mirror still breaks when upstream deletes the repo. Both, where permitted.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub enum Source {
54    /// Compiled into this crate. Small, ours, offline by construction.
55    Bundled,
56    /// Trained by this project and published to our own model repository.
57    Ours { repo: &'static str, rev: &'static str },
58    /// Someone else's weights, fetched from the repository that published them.
59    Upstream { repo: &'static str, license: &'static str, rev: &'static str },
60    /// Someone else's weights that we also host, as the licence permits, so a build does not depend on an
61    /// upstream repository staying where it is. `upstream` is retained because attribution does not transfer.
62    Mirrored {
63        ours: &'static str,
64        upstream: &'static str,
65        license: &'static str,
66        rev: &'static str,
67    },
68}
69
70impl Source {
71    /// The licence a redistribution must carry, if this is not our own work.
72    pub fn license(&self) -> Option<&'static str> {
73        match self {
74            Source::Bundled | Source::Ours { .. } => None,
75            Source::Upstream { license, .. } | Source::Mirrored { license, .. } => Some(license),
76        }
77    }
78    /// Who to credit.
79    pub fn attribution(&self) -> Option<&'static str> {
80        match self {
81            Source::Bundled | Source::Ours { .. } => None,
82            Source::Upstream { repo, .. } => Some(repo),
83            Source::Mirrored { upstream, .. } => Some(upstream),
84        }
85    }
86    /// The revision this build expects. Pinning is what makes a fetch reproducible.
87    pub fn revision(&self) -> &'static str {
88        match self {
89            Source::Bundled => "bundled",
90            Source::Ours { rev, .. } | Source::Upstream { rev, .. } | Source::Mirrored { rev, .. } => rev,
91        }
92    }
93    /// Where to fetch from, preferring our mirror when one exists.
94    pub fn fetch_repo(&self) -> Option<&'static str> {
95        match self {
96            Source::Bundled => None,
97            Source::Ours { repo, .. } => Some(repo),
98            Source::Upstream { repo, .. } => Some(repo),
99            Source::Mirrored { ours, .. } => Some(ours),
100        }
101    }
102}
103
104/// One resolvable set of weights.
105#[derive(Debug, Clone, Copy)]
106pub struct Artifact {
107    /// stable name used by [`resolve`]
108    pub name: &'static str,
109    /// environment variable that overrides the search, if set
110    pub env: &'static str,
111    /// directory name under the model root
112    pub dir: &'static str,
113    /// a file that must exist for the directory to count as present
114    pub marker: &'static str,
115    pub source: Source,
116    pub approx_mb: u32,
117    pub purpose: &'static str,
118}
119
120/// Every artifact the engine knows how to find.
121pub const ARTIFACTS: &[Artifact] = &[
122    Artifact {
123        name: "embeddings",
124        env: "STEELDB_EMBEDDINGS",
125        dir: "bert-tiny",
126        marker: "model.safetensors",
127        source: Source::Mirrored {
128            ours: "cp500/steeldb-models",
129            upstream: "google/bert_uncased_L-2_H-128_A-2",
130            license: "Apache-2.0",
131            rev: "main",
132        },
133        approx_mb: 16,
134        purpose: "the embedding table the bundled heads sit on; 15.2 MB of it is the vocabulary alone",
135    },
136    Artifact {
137        name: "spo-tagger",
138        env: "STEELDB_ML_BUNDLE",
139        dir: "step0_bundle_ml",
140        marker: "spo.onnx",
141        source: Source::Ours { repo: "cp500/steeldb-models", rev: "main" },
142        approx_mb: 168,
143        purpose: "typed span tagger: text becomes ENT/REL/GEO/TIME/QTY spans",
144    },
145    Artifact {
146        name: "splade",
147        env: "STEELDB_SPLADE_DIR",
148        dir: "splade",
149        marker: "splade.onnx",
150        source: Source::Ours { repo: "cp500/steeldb-models", rev: "main" },
151        approx_mb: 107,
152        purpose: "learned facet heads: the projection that builds the bitmap",
153    },
154    Artifact {
155        name: "model2vec",
156        env: "STEELDB_MODEL2VEC",
157        dir: "model2vec",
158        marker: "potion.f32",
159        source: Source::Mirrored {
160            ours: "cp500/steeldb-models",
161            upstream: "minishlab/potion-base-4M",
162            license: "MIT",
163            rev: "main",
164        },
165        approx_mb: 15,
166        purpose: "static embeddings for optimal-transport ontology discovery",
167    },
168    Artifact {
169        name: "needle3",
170        env: "STEELDB_NEEDLE_DIR",
171        dir: "needle3",
172        marker: "needle3.cact",
173        // Apache-2.0, confirmed from the LICENSE file in the model repository — so mirroring is permitted
174        // provided the licence and attribution travel with it.
175        source: Source::Mirrored {
176            ours: "cp500/steeldb-models",
177            upstream: "Cactus-Compute/needle3",
178            license: "Apache-2.0",
179            rev: "b274efcb211a9eef48c9a88da4b43bd569696a39",
180        },
181        approx_mb: 242,
182        purpose: "121M tool-calling model used as the query planner",
183    },
184];
185
186/// Why a set of weights could not be produced.
187#[derive(Debug, Clone)]
188pub enum ModelError {
189    /// no such artifact name
190    Unknown(String),
191    /// searched everywhere and found nothing; carries the instruction to fix it
192    NotFound { name: String, searched: Vec<PathBuf>, hint: String },
193}
194
195impl std::fmt::Display for ModelError {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            ModelError::Unknown(n) => write!(f, "unknown model '{n}'"),
199            ModelError::NotFound { name, searched, hint } => {
200                writeln!(f, "model '{name}' not found. Looked in:")?;
201                for p in searched {
202                    writeln!(f, "  {}", p.display())?;
203                }
204                write!(f, "{hint}")
205            }
206        }
207    }
208}
209
210impl std::error::Error for ModelError {}
211
212/// Look up an artifact by name.
213pub fn artifact(name: &str) -> Option<&'static Artifact> {
214    ARTIFACTS.iter().find(|a| a.name == name)
215}
216
217/// Directories searched for models, in order.
218///
219/// An explicit environment variable wins, then a user-level cache, then a `models/` directory beside the
220/// working tree — which is what makes a checkout work without configuration.
221pub fn search_roots() -> Vec<PathBuf> {
222    let mut roots = Vec::new();
223    if let Ok(home) = std::env::var("HOME") {
224        roots.push(PathBuf::from(home).join(".steeldb").join("models"));
225    }
226    if let Ok(dir) = std::env::var("STEELDB_MODELS") {
227        roots.insert(0, PathBuf::from(dir));
228    }
229    roots.push(PathBuf::from("models"));
230    roots
231}
232
233/// Find the directory holding an artifact, without downloading anything.
234///
235/// Fetching is left to the caller on purpose: a library that reaches for the network on its own is a library
236/// that surprises someone in production. The error says exactly what to run.
237pub fn resolve(name: &str) -> Result<PathBuf, ModelError> {
238    let art = artifact(name).ok_or_else(|| ModelError::Unknown(name.to_string()))?;
239
240    if let Ok(dir) = std::env::var(art.env) {
241        let p = PathBuf::from(dir);
242        if p.join(art.marker).exists() {
243            return Ok(p);
244        }
245    }
246
247    let mut searched = Vec::new();
248    for root in search_roots() {
249        let cand = root.join(art.dir);
250        if cand.join(art.marker).exists() {
251            return Ok(cand);
252        }
253        searched.push(cand);
254    }
255
256    Err(ModelError::NotFound { name: name.to_string(), searched, hint: fetch_hint(art) })
257}
258
259/// The instruction that would make a missing artifact present.
260pub fn fetch_hint(art: &Artifact) -> String {
261    // The destination is the models ROOT, with `--include` selecting the artifact's subdirectory.
262    //
263    // An earlier version pointed `--local-dir` at `<root>/<dir>`, which downloads the whole repository INTO a
264    // directory already named after one artifact: the file lands at `<root>/step0_bundle_ml/step0_bundle_ml/
265    // spo.onnx` and resolution still fails. That could not be caught until the repository actually existed with
266    // subdirectories in it — the command looked plausible against an empty repo.
267    let root = search_roots().first().cloned().unwrap_or_else(|| PathBuf::from("models"));
268    match art.source.fetch_repo() {
269        None => "this artifact ships with the crate; the build is incomplete".to_string(),
270        Some(repo) => {
271            let credit = match art.source.attribution() {
272                Some(up) => format!("\n  {up} — {}", art.source.license().unwrap_or("see model card")),
273                None => String::new(),
274            };
275            format!(
276                "Fetch it ({} MB) with:\n  \
277                 huggingface-cli download {repo} --revision {} --include '{}/*' --local-dir {}\n\
278                 or set {}=/path/to/{}{credit}",
279                art.approx_mb,
280                art.source.revision(),
281                art.dir,
282                root.display(),
283                art.env,
284                art.dir
285            )
286        }
287    }
288}
289
290/// A short report of what is present and what is missing — useful in a CLI or a bug report.
291pub fn status() -> Vec<(&'static str, Option<PathBuf>)> {
292    ARTIFACTS.iter().map(|a| (a.name, resolve(a.name).ok())).collect()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn third_party_weights_keep_their_licence_and_credit() {
301        // The obligations that actually bind when mirroring is permitted: the licence travels with the copy,
302        // and attribution points at whoever trained it — not at us, mirror or no mirror.
303        for a in ARTIFACTS {
304            match a.source {
305                Source::Ours { repo, .. } => {
306                    assert!(repo.starts_with("cp500/"), "{} claims ours but points at {repo}", a.name);
307                    assert!(a.source.license().is_none(), "our own work needs no third-party licence");
308                }
309                Source::Upstream { license, .. } | Source::Mirrored { license, .. } => {
310                    assert!(!license.is_empty(), "{} must record a licence", a.name);
311                    let credit = a.source.attribution().expect("third-party work needs attribution");
312                    assert!(
313                        !credit.starts_with("cp500/"),
314                        "{} credits us for someone else's work",
315                        a.name
316                    );
317                }
318                Source::Bundled => {}
319            }
320        }
321    }
322
323    #[test]
324    fn every_fetched_artifact_pins_a_revision() {
325        // A mirror without a pin still drifts; a pin without a mirror still breaks on deletion. The pin is the
326        // part that makes a build reproducible, so it is required even where we host the copy.
327        for a in ARTIFACTS {
328            if a.source.fetch_repo().is_some() {
329                assert!(!a.source.revision().is_empty(), "{} must pin a revision", a.name);
330            }
331        }
332        // the one we depend on most specifically is pinned to a commit rather than a moving branch
333        let n = artifact("needle3").unwrap();
334        assert_eq!(n.source.revision().len(), 40, "needle3 should pin an exact commit");
335    }
336
337    #[test]
338    fn the_fetch_hint_downloads_into_the_models_ROOT_not_the_artifact_directory() {
339        // The hint used to point `--local-dir` at `<root>/<artifact-dir>`, which downloads the repository INTO a
340        // directory already named after one artifact — the file lands at
341        // `<root>/step0_bundle_ml/step0_bundle_ml/spo.onnx` and resolution still fails. Verified against the real
342        // huggingface-cli: with `--include '<dir>/*' --local-dir <root>` the subdirectory lands directly under
343        // the root, which is where `resolve` looks.
344        std::env::set_var("STEELDB_MODELS", "/models-root");
345        let hint = fetch_hint(artifact("spo-tagger").unwrap());
346        assert!(hint.contains("--include 'step0_bundle_ml/*'"), "must select the subdirectory: {hint}");
347        assert!(hint.contains("--local-dir /models-root"), "must target the ROOT: {hint}");
348        assert!(
349            !hint.contains("--local-dir /models-root/step0_bundle_ml"),
350            "must not nest the artifact directory inside itself: {hint}"
351        );
352        std::env::remove_var("STEELDB_MODELS");
353    }
354
355    #[test]
356    fn a_missing_model_explains_how_to_get_it() {
357        // resolution must never panic, and the error has to be actionable rather than just negative
358        std::env::set_var("STEELDB_MODELS", "/nonexistent-steeldb-test-root");
359        let err = resolve("needle3").unwrap_err();
360        let msg = err.to_string();
361        assert!(msg.contains("not found"), "{msg}");
362        assert!(msg.contains("Cactus-Compute/needle3"), "must credit the author: {msg}");
363        assert!(msg.contains("Apache-2.0"), "must state the licence: {msg}");
364        assert!(msg.contains("--revision"), "must pin a revision: {msg}");
365        assert!(msg.contains("242 MB"), "must state the size: {msg}");
366        std::env::remove_var("STEELDB_MODELS");
367    }
368
369    #[test]
370    fn an_unknown_name_is_an_error_not_a_panic() {
371        assert!(matches!(resolve("no-such-model"), Err(ModelError::Unknown(_))));
372    }
373
374    #[test]
375    fn every_artifact_is_uniquely_named_and_documented() {
376        let mut seen = std::collections::HashSet::new();
377        for a in ARTIFACTS {
378            assert!(seen.insert(a.name), "duplicate artifact name {}", a.name);
379            assert!(!a.purpose.is_empty(), "{} needs a purpose", a.name);
380            assert!(a.env.starts_with("STEELDB_"), "{} env var should be namespaced", a.name);
381        }
382    }
383}