Skip to main content

steeldb/
artifact.rs

1//! **Artefacts** — what `learn` leaves behind, and what `ingest` and `query` follow.
2//!
3//! `learn` is the only step that needs a model. Running it on every startup would make the system slow,
4//! non-deterministic, and dependent on a service being reachable. So it runs **once** and writes its result to
5//! a directory of small JSON files; every run after that reads those files and is offline and reproducible.
6//!
7//! ```text
8//! .hypersteeldb/
9//!   manifest.json      what produced this set, when, and a digest of each part
10//!   vocabulary.json    the categories and the words each claims
11//!   gazetteer.json     multi-word mentions kept whole
12//!   relations.json     relation verbs, with polarity
13//!   motifs.json        latent motifs, so `motif/*` tokens survive a reload
14//! ```
15//!
16//! ```no_run
17//! use steeldb::SteelDb;
18//!
19//! // once — discovers (or learns) and records the result
20//! let db = SteelDb::ingest(["…documents…"])?;
21//! db.save(".hypersteeldb")?;
22//!
23//! // every time after — same vocabulary, no model, no network, byte-identical results
24//! let db = SteelDb::ingest_using(["…documents…"], ".hypersteeldb")?;
25//! # Ok::<(), steeldb::Error>(())
26//! ```
27//!
28//! # What is deliberately *not* in here
29//!
30//! **No document text.** An artefact set is derived vocabulary: category names, the words that signal them,
31//! mention surfaces, relation verbs. It is not a copy of the corpus, and it is not an index. Two reasons that
32//! matters: an artefact set is safe to commit to a repository next to code, and shipping one does not ship the
33//! documents it was learned from. A test asserts the files contain no sentence from the corpus.
34//!
35//! You still need the documents to `ingest`. The artefacts say *how* to read them, not *what* they said.
36
37use std::collections::BTreeMap;
38use std::path::{Path, PathBuf};
39
40/// Current on-disk layout version. Bumped when a field's meaning changes, so an old set is refused rather
41/// than silently misread.
42///
43/// v3 added `motifs.json`. Without it a reload produced no `motif/*` tokens, so the same documents indexed
44/// differently depending on whether they went through discovery or an artefact — the exact drift these files
45/// exist to prevent.
46///
47/// v2 changed `gazetteer` from a list of surfaces to a surface-to-token mapping. v1 lost entity normalisation
48/// on reload: "the Sootopolis City outage" and "Sootopolis City" both register as `entity/sootopolis-city`, but
49/// with only the surfaces recorded a later ingest re-derived the token by slugging the raw text and the two
50/// stopped merging.
51pub const SCHEMA: u32 = 3;
52
53/// File names inside an artefact directory.
54pub const MANIFEST: &str = "manifest.json";
55pub const VOCABULARY: &str = "vocabulary.json";
56pub const GAZETTEER: &str = "gazetteer.json";
57pub const RELATIONS: &str = "relations.json";
58/// Latent motifs: the themes a situation can join without sharing a keyword (the paper's sixth dimension).
59pub const MOTIFS: &str = "motifs.json";
60/// Subdirectory for the finetuning set. Separate because, unlike everything else here, it necessarily
61/// contains document text.
62pub const TRAINING_DIR: &str = "training";
63pub const TRAINING_FILE: &str = "spans.jsonl";
64pub const IGNORE_FILE: &str = ".gitignore";
65
66/// A registered entity mention: how it appears, and what it resolves to.
67#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
68pub struct Registration {
69    /// the surface form as found in the text
70    pub surface: String,
71    /// the canonical token, e.g. `entity/sootopolis-city`
72    pub token: String,
73}
74
75/// One category and the words that put a document in it.
76#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
77pub struct CategoryRecord {
78    pub name: String,
79    pub words: Vec<String>,
80}
81
82/// The complete set. Small, human-readable, and diffable on purpose — a vocabulary change should show up in
83/// review like any other change.
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct Artifacts {
86    pub schema: u32,
87    /// what produced this set: `discovery` for the offline path, or `learn:<model>` when a model proposed it
88    pub producer: String,
89    /// RFC3339 timestamp, or empty where no clock is available
90    pub created: String,
91    pub categories: Vec<CategoryRecord>,
92    /// Registered mentions: the surface as it appears in text, and the canonical token it resolves to.
93    ///
94    /// A mapping rather than a list because registration NORMALISES — leading determiners and trailing status
95    /// words are stripped, so several surfaces can share one token and their postings merge. Recording only the
96    /// surfaces would drop that, and two variants of one entity would become two entities.
97    pub gazetteer: Vec<Registration>,
98    /// relation verbs, canonical form
99    pub relations: Vec<String>,
100    /// latent motifs, in the same shape as a category: a name and the terms that join it
101    #[serde(default)]
102    pub motifs: Vec<CategoryRecord>,
103    /// byte lengths of each written part, so a truncated set is detected on load
104    pub digests: BTreeMap<String, usize>,
105    /// Number of labelled examples written under `training/`, if any.
106    ///
107    /// The count lives in the manifest but the examples do not, because they contain corpus text and the
108    /// manifest is meant to be readable and committable.
109    #[serde(default)]
110    pub training_examples: usize,
111    /// Parts of this set that contain verbatim document text. Always empty for the vocabulary files; lists
112    /// `training/` when a finetuning set was written.
113    ///
114    /// Recorded explicitly so a tool, a reviewer, or a CI check can tell which parts are safe to publish
115    /// without having to know the layout by heart.
116    #[serde(default)]
117    pub contains_document_text: Vec<String>,
118    /// The finetuning set, held in memory. Never serialised into the manifest: it goes to its own file.
119    #[serde(skip)]
120    training: Option<String>,
121}
122
123/// Why an artefact set could not be read or written.
124#[derive(Debug)]
125pub enum ArtifactError {
126    Io(std::io::Error),
127    /// the directory exists but does not hold an artefact set
128    NotAnArtifactDir(PathBuf),
129    /// written by a newer or older layout than this build understands
130    SchemaMismatch { found: u32, expected: u32 },
131    /// a part is present but its size does not match the manifest
132    Corrupt(String),
133    Parse(String),
134}
135
136impl std::fmt::Display for ArtifactError {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self {
139            ArtifactError::Io(e) => write!(f, "{e}"),
140            ArtifactError::NotAnArtifactDir(p) => write!(
141                f,
142                "{} is not an artefact directory (no {MANIFEST}); run save() first",
143                p.display()
144            ),
145            ArtifactError::SchemaMismatch { found, expected } => write!(
146                f,
147                "artefact schema {found} cannot be read by this build (expects {expected}); re-run learn"
148            ),
149            ArtifactError::Corrupt(what) => write!(f, "artefact set is incomplete: {what}"),
150            ArtifactError::Parse(e) => write!(f, "could not parse artefact: {e}"),
151        }
152    }
153}
154
155impl std::error::Error for ArtifactError {}
156
157impl From<std::io::Error> for ArtifactError {
158    fn from(e: std::io::Error) -> Self {
159        ArtifactError::Io(e)
160    }
161}
162
163impl Artifacts {
164    /// Build a set from the parts an ingest produced.
165    pub fn new(
166        producer: impl Into<String>,
167        categories: Vec<CategoryRecord>,
168        gazetteer: Vec<Registration>,
169        relations: Vec<String>,
170    ) -> Artifacts {
171        Artifacts {
172            schema: SCHEMA,
173            producer: producer.into(),
174            created: now_rfc3339(),
175            categories,
176            gazetteer,
177            relations,
178            motifs: Vec::new(),
179            digests: BTreeMap::new(),
180            training_examples: 0,
181            contains_document_text: Vec::new(),
182            training: None,
183        }
184    }
185
186    /// Attach a finetuning set: JSONL lines, one labelled passage each.
187    ///
188    /// This is the one part of an artefact set that carries document text — a span label is meaningless
189    /// without the words it points at. It is therefore written to a subdirectory, recorded in
190    /// `contains_document_text`, and excluded by a `.gitignore` that [`Artifacts::save`] writes, so committing
191    /// the artefact directory does not commit the corpus by accident.
192    pub fn with_training(mut self, jsonl: impl Into<String>) -> Artifacts {
193        let jsonl = jsonl.into();
194        self.training_examples = jsonl.lines().filter(|l| !l.trim().is_empty()).count();
195        self.contains_document_text = vec![format!("{TRAINING_DIR}/")];
196        self.training = Some(jsonl);
197        self
198    }
199
200    /// The finetuning set, if this build of the artefacts carries one in memory.
201    pub fn training(&self) -> Option<&str> {
202        self.training.as_deref()
203    }
204
205    /// Write the set to `dir`, creating it if needed.
206    ///
207    /// Each part is written separately so a reviewer sees a small, meaningful diff rather than one large blob.
208    /// Record the discovered motifs, so a reload projects the same `motif/*` tokens discovery did.
209    pub fn with_motifs(mut self, motifs: Vec<CategoryRecord>) -> Artifacts {
210        self.motifs = motifs;
211        self
212    }
213
214    pub fn save(&self, dir: impl AsRef<Path>) -> Result<(), ArtifactError> {
215        let dir = dir.as_ref();
216        std::fs::create_dir_all(dir)?;
217
218        let vocab = serde_json::to_vec_pretty(&self.categories).map_err(|e| ArtifactError::Parse(e.to_string()))?;
219        let gaz = serde_json::to_vec_pretty(&self.gazetteer).map_err(|e| ArtifactError::Parse(e.to_string()))?;
220        let rel = serde_json::to_vec_pretty(&self.relations).map_err(|e| ArtifactError::Parse(e.to_string()))?;
221        let mot = serde_json::to_vec_pretty(&self.motifs).map_err(|e| ArtifactError::Parse(e.to_string()))?;
222
223        let mut manifest = self.clone();
224        manifest.digests.clear();
225        manifest.digests.insert(VOCABULARY.into(), vocab.len());
226        manifest.digests.insert(GAZETTEER.into(), gaz.len());
227        manifest.digests.insert(RELATIONS.into(), rel.len());
228        manifest.digests.insert(MOTIFS.into(), mot.len());
229        // the manifest carries only counts and provenance; the parts carry the content
230        let mut slim = manifest.clone();
231        slim.training = None;
232        slim.categories = Vec::new();
233        slim.gazetteer = Vec::new();
234        slim.relations = Vec::new();
235        slim.motifs = Vec::new();
236
237        // Write the ignore file FIRST, so the training set can never exist in the directory without the rule
238        // that keeps it out of a commit.
239        std::fs::write(
240            dir.join(IGNORE_FILE),
241            format!(
242                "# Written by hypersteeldb. The finetuning set under {TRAINING_DIR}/ contains verbatim document\n\
243                 # text, because a span label is meaningless without the words it points at. Everything else in\n\
244                 # this directory is derived vocabulary and is safe to commit.\n\
245                 {TRAINING_DIR}/\n"
246            ),
247        )?;
248        if let Some(jsonl) = &self.training {
249            let tdir = dir.join(TRAINING_DIR);
250            std::fs::create_dir_all(&tdir)?;
251            std::fs::write(tdir.join(TRAINING_FILE), jsonl.as_bytes())?;
252        }
253
254        std::fs::write(dir.join(VOCABULARY), &vocab)?;
255        std::fs::write(dir.join(GAZETTEER), &gaz)?;
256        std::fs::write(dir.join(RELATIONS), &rel)?;
257        std::fs::write(dir.join(MOTIFS), &mot)?;
258        std::fs::write(
259            dir.join(MANIFEST),
260            serde_json::to_vec_pretty(&slim).map_err(|e| ArtifactError::Parse(e.to_string()))?,
261        )?;
262        Ok(())
263    }
264
265    /// Read a set from `dir`, refusing anything this build cannot interpret correctly.
266    pub fn load(dir: impl AsRef<Path>) -> Result<Artifacts, ArtifactError> {
267        let dir = dir.as_ref();
268        let mpath = dir.join(MANIFEST);
269        if !mpath.exists() {
270            return Err(ArtifactError::NotAnArtifactDir(dir.to_path_buf()));
271        }
272        let mut set: Artifacts = serde_json::from_slice(&std::fs::read(&mpath)?)
273            .map_err(|e| ArtifactError::Parse(e.to_string()))?;
274        if set.schema != SCHEMA {
275            return Err(ArtifactError::SchemaMismatch { found: set.schema, expected: SCHEMA });
276        }
277
278        let vocab = std::fs::read(dir.join(VOCABULARY))?;
279        let gaz = std::fs::read(dir.join(GAZETTEER))?;
280        let rel = std::fs::read(dir.join(RELATIONS))?;
281        let mot = std::fs::read(dir.join(MOTIFS))?;
282
283        // A truncated part would otherwise load as a smaller vocabulary and answer questions with it — a
284        // quietly wrong result rather than a failure.
285        for (name, actual) in
286            [(VOCABULARY, vocab.len()), (GAZETTEER, gaz.len()), (RELATIONS, rel.len()), (MOTIFS, mot.len())]
287        {
288            if let Some(expected) = set.digests.get(name) {
289                if *expected != actual {
290                    return Err(ArtifactError::Corrupt(format!(
291                        "{name} is {actual} bytes, manifest says {expected}"
292                    )));
293                }
294            }
295        }
296
297        set.categories = serde_json::from_slice(&vocab).map_err(|e| ArtifactError::Parse(e.to_string()))?;
298        set.gazetteer = serde_json::from_slice(&gaz).map_err(|e| ArtifactError::Parse(e.to_string()))?;
299        set.relations = serde_json::from_slice(&rel).map_err(|e| ArtifactError::Parse(e.to_string()))?;
300        set.motifs = serde_json::from_slice(&mot).map_err(|e| ArtifactError::Parse(e.to_string()))?;
301        // the training set is optional and may legitimately be absent — it is gitignored, so a cloned
302        // repository will have the vocabulary without it, and ingest/query do not need it
303        let tpath = dir.join(TRAINING_DIR).join(TRAINING_FILE);
304        set.training = std::fs::read_to_string(&tpath).ok();
305        Ok(set)
306    }
307
308    /// True when `dir` holds a readable artefact set.
309    pub fn exists(dir: impl AsRef<Path>) -> bool {
310        dir.as_ref().join(MANIFEST).exists()
311    }
312}
313
314/// An RFC3339 timestamp, or empty on targets without a clock (wasm has none).
315fn now_rfc3339() -> String {
316    #[cfg(not(target_arch = "wasm32"))]
317    {
318        use std::time::{SystemTime, UNIX_EPOCH};
319        let Ok(d) = SystemTime::now().duration_since(UNIX_EPOCH) else { return String::new() };
320        let secs = d.as_secs() as i64;
321        // civil-from-days, so no date dependency is needed for a timestamp
322        let days = secs.div_euclid(86_400);
323        let tod = secs.rem_euclid(86_400);
324        let z = days + 719_468;
325        let era = z.div_euclid(146_097);
326        let doe = z.rem_euclid(146_097);
327        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
328        let y = yoe + era * 400;
329        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
330        let mp = (5 * doy + 2) / 153;
331        let d_ = doy - (153 * mp + 2) / 5 + 1;
332        let m = if mp < 10 { mp + 3 } else { mp - 9 };
333        let y = if m <= 2 { y + 1 } else { y };
334        format!(
335            "{y:04}-{m:02}-{d_:02}T{:02}:{:02}:{:02}Z",
336            tod / 3600,
337            (tod % 3600) / 60,
338            tod % 60
339        )
340    }
341    #[cfg(target_arch = "wasm32")]
342    {
343        String::new()
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn tmp(name: &str) -> PathBuf {
352        let p = std::env::temp_dir().join(format!("hsdb_artifact_{}_{name}", std::process::id()));
353        let _ = std::fs::remove_dir_all(&p);
354        p
355    }
356
357    fn sample() -> Artifacts {
358        Artifacts::new(
359            "discovery",
360            vec![
361                CategoryRecord { name: "battle".into(), words: vec!["defeated".into(), "faced".into()] },
362                CategoryRecord { name: "survey".into(), words: vec!["elevation".into()] },
363            ],
364            vec![
365                Registration { surface: "Sootopolis City".into(), token: "entity/sootopolis-city".into() },
366                Registration { surface: "Indigo Invitational".into(), token: "entity/indigo-invitational".into() },
367            ],
368            vec!["defeated".into(), "documented".into()],
369        )
370        .with_motifs(vec![CategoryRecord {
371            name: "series".into(),
372            words: vec!["series".into(), "season".into()],
373        }])
374    }
375
376    #[test]
377    fn a_set_round_trips_exactly() {
378        let dir = tmp("roundtrip");
379        let a = sample();
380        a.save(&dir).unwrap();
381        let b = Artifacts::load(&dir).unwrap();
382        assert_eq!(b.schema, SCHEMA);
383        assert_eq!(b.producer, "discovery");
384        assert_eq!(b.categories.len(), 2);
385        assert_eq!(b.categories[0].name, "battle");
386        assert_eq!(b.gazetteer, a.gazetteer);
387        assert_eq!(b.relations, a.relations);
388        // motifs travel too, or a reload projects no motif/* tokens and indexes differently from discovery
389        assert_eq!(b.motifs.len(), 1);
390        assert_eq!(b.motifs[0].name, "series");
391        assert_eq!(b.motifs[0].words, a.motifs[0].words);
392        let _ = std::fs::remove_dir_all(&dir);
393    }
394
395    #[test]
396    fn the_files_contain_no_document_text() {
397        // The leakage guard. An artefact set is derived vocabulary, so it is safe to commit and safe to ship;
398        // if a sentence from the corpus could appear here, neither would be true.
399        let dir = tmp("leak");
400        let sentence = "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational.";
401        sample().save(&dir).unwrap();
402        for f in [MANIFEST, VOCABULARY, GAZETTEER, RELATIONS, MOTIFS] {
403            let text = std::fs::read_to_string(dir.join(f)).unwrap();
404            assert!(!text.contains(sentence), "{f} contains a corpus sentence");
405            assert!(!text.contains("Morty Shade defeated"), "{f} contains a document fragment");
406        }
407        let _ = std::fs::remove_dir_all(&dir);
408    }
409
410    #[test]
411    fn a_missing_set_says_what_to_do() {
412        let dir = tmp("missing");
413        std::fs::create_dir_all(&dir).unwrap();
414        let err = Artifacts::load(&dir).unwrap_err();
415        let msg = err.to_string();
416        assert!(msg.contains("not an artefact directory"), "{msg}");
417        assert!(msg.contains("save()"), "must say how to create one: {msg}");
418        let _ = std::fs::remove_dir_all(&dir);
419    }
420
421    #[test]
422    fn a_truncated_part_is_refused_not_silently_loaded() {
423        // A shorter vocabulary file would otherwise load as a smaller vocabulary and answer questions with
424        // it — wrong, and invisible.
425        let dir = tmp("truncated");
426        sample().save(&dir).unwrap();
427        std::fs::write(dir.join(VOCABULARY), b"[]").unwrap();
428        let err = Artifacts::load(&dir).unwrap_err();
429        assert!(matches!(err, ArtifactError::Corrupt(_)), "{err}");
430        let _ = std::fs::remove_dir_all(&dir);
431    }
432
433    #[test]
434    fn a_future_schema_is_refused() {
435        let dir = tmp("schema");
436        let mut a = sample();
437        a.save(&dir).unwrap();
438        a.schema = SCHEMA + 1;
439        let mut slim = a.clone();
440        slim.categories = Vec::new();
441        slim.gazetteer = Vec::new();
442        slim.relations = Vec::new();
443        std::fs::write(dir.join(MANIFEST), serde_json::to_vec_pretty(&slim).unwrap()).unwrap();
444        assert!(matches!(
445            Artifacts::load(&dir).unwrap_err(),
446            ArtifactError::SchemaMismatch { .. }
447        ));
448        let _ = std::fs::remove_dir_all(&dir);
449    }
450
451    #[test]
452    fn the_manifest_records_provenance() {
453        let dir = tmp("provenance");
454        Artifacts::new("learn:local:qwen2.5-0.5b", vec![], vec![], vec![]).save(&dir).unwrap();
455        let loaded = Artifacts::load(&dir).unwrap();
456        assert_eq!(loaded.producer, "learn:local:qwen2.5-0.5b");
457        assert!(loaded.created.contains('T') || loaded.created.is_empty());
458        let _ = std::fs::remove_dir_all(&dir);
459    }
460
461    #[test]
462    fn the_finetuning_set_is_separated_and_gitignored() {
463        // A span label is meaningless without the words it points at, so this ONE part must carry document
464        // text. It therefore has to be impossible to commit it by accident while committing the vocabulary.
465        let dir = tmp("training");
466        let jsonl = "{\"text\":\"Morty Shade defeated Wallace Gale.\",\"spans\":[]}\n\
467                     {\"text\":\"A survey recorded Aggron at 1082 m.\",\"spans\":[]}\n";
468        sample().with_training(jsonl).save(&dir).unwrap();
469
470        // the ignore rule exists and covers the training directory
471        let ignore = std::fs::read_to_string(dir.join(IGNORE_FILE)).unwrap();
472        assert!(ignore.contains(&format!("{TRAINING_DIR}/")), "{ignore}");
473
474        // the text is in the training file
475        let train = std::fs::read_to_string(dir.join(TRAINING_DIR).join(TRAINING_FILE)).unwrap();
476        assert!(train.contains("Morty Shade defeated"));
477
478        // and NOT in any of the committable parts
479        for f in [MANIFEST, VOCABULARY, GAZETTEER, RELATIONS, MOTIFS] {
480            let text = std::fs::read_to_string(dir.join(f)).unwrap();
481            assert!(!text.contains("Morty Shade defeated"), "{f} leaked document text");
482        }
483
484        // the manifest declares which parts are unsafe to publish, so a CI check need not know the layout
485        let loaded = Artifacts::load(&dir).unwrap();
486        assert_eq!(loaded.training_examples, 2);
487        assert_eq!(loaded.contains_document_text, vec![format!("{TRAINING_DIR}/")]);
488        let _ = std::fs::remove_dir_all(&dir);
489    }
490
491    #[test]
492    fn a_vocabulary_only_set_declares_no_text_and_still_loads() {
493        // The common case after cloning a repository: the vocabulary is committed, the gitignored training set
494        // is absent. ingest and query must work from that alone.
495        let dir = tmp("novocab");
496        sample().save(&dir).unwrap();
497        let loaded = Artifacts::load(&dir).unwrap();
498        assert!(loaded.contains_document_text.is_empty(), "nothing here carries document text");
499        assert_eq!(loaded.training_examples, 0);
500        assert!(loaded.training().is_none());
501        assert_eq!(loaded.categories.len(), 2, "the vocabulary is complete without the training set");
502        let _ = std::fs::remove_dir_all(&dir);
503    }
504
505    #[test]
506    fn the_ignore_rule_is_written_even_without_a_training_set() {
507        // Otherwise a later run that DOES produce one would drop it into an unguarded directory.
508        let dir = tmp("ignorefirst");
509        sample().save(&dir).unwrap();
510        assert!(dir.join(IGNORE_FILE).exists(), "the rule must exist before the data can");
511        let _ = std::fs::remove_dir_all(&dir);
512    }
513}