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-8M",
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    let dest = search_roots()
262        .first()
263        .map(|r| r.join(art.dir))
264        .unwrap_or_else(|| PathBuf::from("models").join(art.dir));
265    match art.source.fetch_repo() {
266        None => "this artifact ships with the crate; the build is incomplete".to_string(),
267        Some(repo) => {
268            let credit = match art.source.attribution() {
269                Some(up) => format!("\n  {up} — {}", art.source.license().unwrap_or("see model card")),
270                None => String::new(),
271            };
272            format!(
273                "Fetch it ({} MB) with:\n  \
274                 huggingface-cli download {repo} --revision {} --local-dir {}\n\
275                 or set {}=/path/to/{}{credit}",
276                art.approx_mb,
277                art.source.revision(),
278                dest.display(),
279                art.env,
280                art.dir
281            )
282        }
283    }
284}
285
286/// A short report of what is present and what is missing — useful in a CLI or a bug report.
287pub fn status() -> Vec<(&'static str, Option<PathBuf>)> {
288    ARTIFACTS.iter().map(|a| (a.name, resolve(a.name).ok())).collect()
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn third_party_weights_keep_their_licence_and_credit() {
297        // The obligations that actually bind when mirroring is permitted: the licence travels with the copy,
298        // and attribution points at whoever trained it — not at us, mirror or no mirror.
299        for a in ARTIFACTS {
300            match a.source {
301                Source::Ours { repo, .. } => {
302                    assert!(repo.starts_with("cp500/"), "{} claims ours but points at {repo}", a.name);
303                    assert!(a.source.license().is_none(), "our own work needs no third-party licence");
304                }
305                Source::Upstream { license, .. } | Source::Mirrored { license, .. } => {
306                    assert!(!license.is_empty(), "{} must record a licence", a.name);
307                    let credit = a.source.attribution().expect("third-party work needs attribution");
308                    assert!(
309                        !credit.starts_with("cp500/"),
310                        "{} credits us for someone else's work",
311                        a.name
312                    );
313                }
314                Source::Bundled => {}
315            }
316        }
317    }
318
319    #[test]
320    fn every_fetched_artifact_pins_a_revision() {
321        // A mirror without a pin still drifts; a pin without a mirror still breaks on deletion. The pin is the
322        // part that makes a build reproducible, so it is required even where we host the copy.
323        for a in ARTIFACTS {
324            if a.source.fetch_repo().is_some() {
325                assert!(!a.source.revision().is_empty(), "{} must pin a revision", a.name);
326            }
327        }
328        // the one we depend on most specifically is pinned to a commit rather than a moving branch
329        let n = artifact("needle3").unwrap();
330        assert_eq!(n.source.revision().len(), 40, "needle3 should pin an exact commit");
331    }
332
333    #[test]
334    fn a_missing_model_explains_how_to_get_it() {
335        // resolution must never panic, and the error has to be actionable rather than just negative
336        std::env::set_var("STEELDB_MODELS", "/nonexistent-steeldb-test-root");
337        let err = resolve("needle3").unwrap_err();
338        let msg = err.to_string();
339        assert!(msg.contains("not found"), "{msg}");
340        assert!(msg.contains("Cactus-Compute/needle3"), "must credit the author: {msg}");
341        assert!(msg.contains("Apache-2.0"), "must state the licence: {msg}");
342        assert!(msg.contains("--revision"), "must pin a revision: {msg}");
343        assert!(msg.contains("242 MB"), "must state the size: {msg}");
344        std::env::remove_var("STEELDB_MODELS");
345    }
346
347    #[test]
348    fn an_unknown_name_is_an_error_not_a_panic() {
349        assert!(matches!(resolve("no-such-model"), Err(ModelError::Unknown(_))));
350    }
351
352    #[test]
353    fn every_artifact_is_uniquely_named_and_documented() {
354        let mut seen = std::collections::HashSet::new();
355        for a in ARTIFACTS {
356            assert!(seen.insert(a.name), "duplicate artifact name {}", a.name);
357            assert!(!a.purpose.is_empty(), "{} needs a purpose", a.name);
358            assert!(a.env.starts_with("STEELDB_"), "{} env var should be namespaced", a.name);
359        }
360    }
361}