Skip to main content

steeldb/
api.rs

1//! **The public API.** One type to learn, and a return type that makes refusal impossible to ignore.
2//!
3//! ```no_run
4//! use steeldb::SteelDb;
5//!
6//! let db = SteelDb::ingest(["Morty Shade defeated Wallace Gale at Ecruteak City in 2025."])?;
7//!
8//! match db.query("(and defeated/* (not state/negated))") {
9//!     Ok(answer)  => println!("{} situations", answer.len()),
10//!     Err(refused) => println!("{refused}"),   // names what the data *does* contain
11//! }
12//! # Ok::<(), steeldb::api::Error>(())
13//! ```
14//!
15//! ## Why `query` returns a `Result`
16//!
17//! Most engines answer everything. Ask for something absent and you get an empty list, which is
18//! indistinguishable from "there are genuinely none" — and for an automated caller, indistinguishable from a
19//! correct answer. The whole argument of this engine is that it can say **no**, so the API makes that outcome a
20//! separate branch you have to look at rather than a value you can skim past.
21//!
22//! [`Refused`] carries what *does* exist, so a caller — human or agent — can repair the question instead of
23//! guessing again.
24
25use crate::bitmap::{Postings, RoarPostings};
26use crate::db::Corpus;
27use crate::projector::CorpusKind;
28use std::collections::BTreeMap;
29use std::path::Path;
30
31type P = RoarPostings;
32
33// ── results ───────────────────────────────────────────────────────────────────────────────────────────
34
35/// A complete set of matching situations.
36///
37/// Complete, not ranked: every situation satisfying the query is here, so counting is meaningful. Iterate it
38/// directly, or read [`Answer::ids`].
39#[derive(Debug, Clone, Default)]
40pub struct Answer {
41    ids: Vec<u32>,
42    micros: f64,
43}
44
45impl Answer {
46    /// How many situations matched.
47    pub fn len(&self) -> usize {
48        self.ids.len()
49    }
50    pub fn is_empty(&self) -> bool {
51        self.ids.is_empty()
52    }
53    /// The matching situation ids, ascending.
54    pub fn ids(&self) -> &[u32] {
55        &self.ids
56    }
57    /// Wall-clock microseconds for the bitmap program. Zero on targets without a monotonic clock (wasm).
58    pub fn micros(&self) -> f64 {
59        self.micros
60    }
61}
62
63impl IntoIterator for Answer {
64    type Item = u32;
65    type IntoIter = std::vec::IntoIter<u32>;
66    fn into_iter(self) -> Self::IntoIter {
67        self.ids.into_iter()
68    }
69}
70
71impl<'a> IntoIterator for &'a Answer {
72    type Item = &'a u32;
73    type IntoIter = std::slice::Iter<'a, u32>;
74    fn into_iter(self) -> Self::IntoIter {
75        self.ids.iter()
76    }
77}
78
79/// A query the data cannot answer, and what it can answer instead.
80///
81/// This is the type that carries the engine's central promise. It is an error rather than an empty result
82/// because those are different facts, and conflating them is how a confident wrong answer gets produced.
83#[derive(Debug, Clone)]
84pub struct Refused {
85    /// the query as given
86    pub query: String,
87    /// one line per problem, already phrased for a reader
88    pub problems: Vec<String>,
89    /// tags or categories that do exist and are close to what was asked
90    pub alternatives: Vec<String>,
91}
92
93/// Break a long line at comma boundaries so a refusal stays readable in a terminal and in a README.
94///
95/// A refusal that lists a corpus's dimensions runs well past any sensible width, and the one thing it must not
96/// be is hard to read — it is the message a user gets at precisely the moment they are confused.
97fn wrap_indented(text: &str, width: usize, indent: &str) -> String {
98    let mut out = String::new();
99    let mut line = String::new();
100    for (i, piece) in text.split(", ").enumerate() {
101        let sep = if i == 0 { "" } else { ", " };
102        if !line.is_empty() && line.chars().count() + sep.len() + piece.chars().count() > width {
103            // the comma stays at the end of the line it broke, or the list reads as if an item were dropped
104            out.push_str(&line);
105            out.push(',');
106            out.push('\n');
107            out.push_str(indent);
108            line = piece.to_string();
109        } else {
110            line.push_str(sep);
111            line.push_str(piece);
112        }
113    }
114    out.push_str(&line);
115    out
116}
117
118impl std::fmt::Display for Refused {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        write!(f, "refused: {}", self.query)?;
121        for p in &self.problems {
122            write!(f, "\n  {}", wrap_indented(p, 70, "    "))?;
123        }
124        if !self.alternatives.is_empty() {
125            write!(f, "\n  available: {}", wrap_indented(&self.alternatives.join(", "), 59, "             "))?;
126        }
127        Ok(())
128    }
129}
130
131impl std::error::Error for Refused {}
132
133/// The evidential bound on a claim: `[belief, plausibility]`.
134///
135/// Two numbers rather than one, because a single probability cannot separate a contested claim from an
136/// unexamined one — both come out near the middle. See the module docs of [`crate::evidence`].
137#[derive(Debug, Clone, Copy, PartialEq)]
138pub struct Interval {
139    /// evidence positively supporting the claim — the floor
140    pub belief: f64,
141    /// evidence not ruling it out — the ceiling
142    pub plausibility: f64,
143}
144
145impl Interval {
146    /// The width of the interval: how much the corpus simply does not say.
147    pub fn ignorance(&self) -> f64 {
148        (self.plausibility - self.belief).max(0.0)
149    }
150    /// Established: supported and unrefuted.
151    pub fn is_certain(&self) -> bool {
152        self.belief >= 1.0 - f64::EPSILON
153    }
154    /// Refuted: nothing supports it and something rules it out.
155    pub fn is_refuted(&self) -> bool {
156        self.plausibility <= f64::EPSILON
157    }
158    /// Nobody said: no support, nothing against.
159    pub fn is_unknown(&self) -> bool {
160        self.belief <= f64::EPSILON && self.plausibility >= 1.0 - f64::EPSILON
161    }
162}
163
164impl std::fmt::Display for Interval {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        write!(f, "[{:.2}, {:.2}]", self.belief, self.plausibility)
167    }
168}
169
170/// One discovered category and the words it claims.
171#[derive(Debug, Clone, Copy)]
172pub struct Category<'a> {
173    /// the category name, which is also its query stem: `name/*`
174    pub name: &'a str,
175    /// the words in the text that put a document in this category
176    pub words: &'a [String],
177}
178
179impl Category<'_> {
180    /// The wildcard that matches every value in this category.
181    pub fn wildcard(&self) -> String {
182        format!("{}/*", self.name)
183    }
184}
185
186/// Setup failures, kept separate from query refusals.
187#[derive(Debug)]
188pub enum Error {
189    /// nothing usable was found to index
190    Empty(String),
191    /// an artefact set could not be read or written
192    Artifact(crate::artifact::ArtifactError),
193    /// neural discovery could not run — a model was missing, or the tagger failed
194    #[cfg(all(feature = "onnx", feature = "embed"))]
195    Tagger(crate::tagger_discover::TaggerError),
196    Io(std::io::Error),
197}
198
199impl std::fmt::Display for Error {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        match self {
202            Error::Empty(what) => write!(f, "nothing to index: {what}"),
203            Error::Artifact(e) => write!(f, "{e}"),
204            #[cfg(all(feature = "onnx", feature = "embed"))]
205            Error::Tagger(e) => write!(f, "{e}"),
206            Error::Io(e) => write!(f, "{e}"),
207        }
208    }
209}
210
211impl std::error::Error for Error {}
212
213impl From<std::io::Error> for Error {
214    fn from(e: std::io::Error) -> Self {
215        Error::Io(e)
216    }
217}
218
219// ── the handle ────────────────────────────────────────────────────────────────────────────────────────
220
221/// How to build the vocabulary. The defaults are tuned for prose and rarely need changing.
222#[derive(Debug, Clone)]
223pub struct Options {
224    /// how much candidate vocabulary to consider
225    pub terms: usize,
226    /// how many categories to look for
227    pub categories: usize,
228    /// a candidate must clear `coverage × (1 − overlap)` to be kept
229    pub min_gain: f64,
230}
231
232impl Default for Options {
233    fn default() -> Self {
234        Options { terms: 90, categories: 6, min_gain: 0.05 }
235    }
236}
237
238/// One document's projection, produced on a worker thread and added to the index in document order.
239struct Projected {
240    tags: Vec<String>,
241    display: Vec<String>,
242    numbers: Vec<(String, f64)>,
243    beliefs: Vec<(String, f32)>,
244}
245
246/// An indexed corpus you can ask questions of.
247///
248/// Read-only after construction and `Send + Sync`, so one instance can serve many threads.
249pub struct SteelDb {
250    corpus: Corpus,
251    categories: Vec<(String, Vec<String>)>,
252    /// latent motifs: the themes `motif/*` tokens come from (the paper's sixth dimension)
253    motifs: Vec<(String, Vec<String>)>,
254    /// the gazetteer the corpus was actually projected with — retained so `save` persists what was USED, not a
255    /// re-mined approximation. Neural discovery mines a different gazetteer than the lexical path, so re-mining
256    /// at save time produced an artefact that reloaded to different tags.
257    registrations: Vec<crate::artifact::Registration>,
258    documents: Vec<String>,
259    /// retained so `learn` gates adopted candidates on the same threshold ingest used
260    min_gain: f64,
261}
262
263/// Terms considered when looking for latent motifs. Fewer than for categories on purpose: a motif is a broad
264/// theme, and the long tail of rare terms adds noise rather than themes.
265const MOTIF_TERMS: usize = 40;
266/// How many motifs to look for. The transport solver clamps this down on a small corpus.
267const MOTIF_GROUPS: usize = 3;
268
269impl SteelDb {
270    /// **Ingest.** Discover a vocabulary from documents and index them.
271    ///
272    /// Offline and deterministic: no credentials, no network, no model files. The same documents always give
273    /// the same vocabulary.
274    pub fn ingest<I, S>(docs: I) -> Result<Self, Error>
275    where
276        I: IntoIterator<Item = S>,
277        S: AsRef<str>,
278    {
279        Self::ingest_with(docs, Options::default())
280    }
281
282    /// As [`SteelDb::ingest`], with explicit discovery settings.
283    pub fn ingest_with<I, S>(docs: I, opts: Options) -> Result<Self, Error>
284    where
285        I: IntoIterator<Item = S>,
286        S: AsRef<str>,
287    {
288        let documents: Vec<String> =
289            docs.into_iter().map(|d| d.as_ref().trim().to_string()).filter(|d| !d.is_empty()).collect();
290        if documents.is_empty() {
291            return Err(Error::Empty("no non-empty documents".into()));
292        }
293
294        // discover categories, keeping only those that earn their place
295        let clusters = crate::emergent::discover(&documents, opts.terms, opts.categories);
296        let mut spec = crate::vocabulary::VocabularySpace {
297            version: 1,
298            corpus: "documents".into(),
299            entity_facets: Vec::new(),
300            relation_facets: Vec::new(),
301            gazetteer: Vec::new(),
302            metrics: None,
303        };
304        let mut categories: Vec<(String, Vec<String>)> = Vec::new();
305        for (round, c) in clusters.iter().enumerate() {
306            let cand = crate::grow::Candidate {
307                name: c.label.clone(),
308                parent: None,
309                description: String::new(),
310                examples: c.terms.clone(),
311                worth_adding: true,
312            };
313            let scored = crate::grow::score_candidate_full(&spec, &documents, &cand);
314            let (score, dup) = match scored {
315                Some((s, d)) => (Some(s), d),
316                None => (None, None),
317            };
318            if crate::grow::gate_full(&spec, &cand, score.as_ref(), dup, opts.min_gain, round).kept {
319                crate::grow::adopt(&mut spec, &cand);
320                categories.push((c.label.clone(), c.terms.clone()));
321            }
322        }
323
324        // Latent motifs are not gated the way categories are: they are not competing for a retrieval head, they
325        // are themes a situation can join through terms that travel together, and nothing is refused on their
326        // account. They do have to be LATENT, though. Run over the same geometry, transport often recovers the
327        // groups the categories already name, and on a small corpus every motif came back as an exact restatement
328        // of a category — `motif/defeated` beside `defeated/*`, carrying the same situations under a second name.
329        // A motif that a category already expresses is dropped, so `motif/*` means "structure the categories did
330        // not capture" and an empty motif set is the honest answer rather than a padded one.
331        let motifs: Vec<(String, Vec<String>)> =
332            crate::emergent::discover_motifs(&documents, MOTIF_TERMS, MOTIF_GROUPS)
333                .into_iter()
334                .filter(|(name, _)| !categories.iter().any(|(cat, _)| cat == name))
335                .collect();
336
337        let registrations = Self::registrations(&documents);
338        let corpus = Self::project_with(&documents, &categories, &registrations, &motifs);
339        Ok(SteelDb { corpus, categories, motifs, registrations, documents, min_gain: opts.min_gain })
340    }
341
342    /// **Index a corpus against a curated ontology.** The third stage of neural discovery.
343    ///
344    /// The full pipeline is three explicit steps, because each is a different kind of work:
345    ///
346    /// ```no_run
347    /// # #[cfg(all(feature = "onnx", feature = "embed", feature = "paddock"))]
348    /// # async fn demo(documents: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
349    /// use steeldb::{SteelDb, learn::Teacher, tagger_discover};
350    ///
351    /// // 1. mechanical: the tagger reads typed spans, transport groups them into RAW clusters
352    /// let sample: Vec<String> = documents.iter().take(100).cloned().collect();
353    /// let raw = tagger_discover::discover(&sample, 12, 8)?;
354    ///
355    /// // 2. judgment: merge synonyms, drop noise, name each surviving facet as a KIND
356    /// let curated = Teacher::ollama("qwen3:1.7b")?.curate(&raw).await?;
357    ///
358    /// // 3. deterministic: gate the facets and index the FULL corpus, model-free
359    /// let db = SteelDb::ingest_curated(documents, &curated, &raw.surfaces())?;
360    /// # Ok(()) }
361    /// ```
362    ///
363    /// Step 2 cannot be skipped. A raw cluster's label is a *value*, not a facet: the reference's own committed
364    /// spec has clusters called `ph` and `ge`, and only curation turns those into `weapon-platform` and
365    /// `control-system` with the raw terms demoted to examples. Indexing raw clusters directly puts `ph` in the
366    /// index as a retrieval dimension.
367    ///
368    /// The models run **once, over the sample**. What this produces is the ordinary artefact vocabulary, and the
369    /// whole corpus is then projected by it with the model-free matcher — so [`SteelDb::query`] stays model-free,
370    /// and after [`SteelDb::save`] later runs need no model at all.
371    ///
372    /// `surfaces` is the observed entity spans from the raw spec, which becomes the gazetteer. It is kept
373    /// separate from curation deliberately: the surfaces are what the tagger actually saw, so they survive
374    /// whatever the curator chooses to merge or drop.
375    pub fn ingest_curated<I, S>(
376        docs: I,
377        curated: &crate::learn::Proposal,
378        surfaces: &[String],
379    ) -> Result<Self, Error>
380    where
381        I: IntoIterator<Item = S>,
382        S: AsRef<str>,
383    {
384        let documents: Vec<String> =
385            docs.into_iter().map(|d| d.as_ref().trim().to_string()).filter(|d| !d.is_empty()).collect();
386        if documents.is_empty() {
387            return Err(Error::Empty("no non-empty documents".into()));
388        }
389
390        // The same MECE gate lexical discovery uses. Curation is judgment and can still be wrong, so a curated
391        // facet earns its retrieval head on identical terms to a locally-discovered one.
392        let mut spec = crate::vocabulary::VocabularySpace {
393            version: 1,
394            corpus: "documents".into(),
395            entity_facets: Vec::new(),
396            relation_facets: Vec::new(),
397            gazetteer: Vec::new(),
398            metrics: None,
399        };
400        let min_gain = Options::default().min_gain;
401        let mut categories: Vec<(String, Vec<String>)> = Vec::new();
402        for (round, cand) in curated.candidates.iter().enumerate() {
403            let c = crate::grow::Candidate {
404                name: cand.name.clone(),
405                parent: None,
406                description: cand.rationale.clone(),
407                examples: cand.words.clone(),
408                worth_adding: true,
409            };
410            let (score, dup) = match crate::grow::score_candidate_full(&spec, &documents, &c) {
411                Some((s, d)) => (Some(s), d),
412                None => (None, None),
413            };
414            if crate::grow::gate_full(&spec, &c, score.as_ref(), dup, min_gain, round).kept {
415                crate::grow::adopt(&mut spec, &c);
416                categories.push((cand.name.clone(), cand.words.clone()));
417            }
418        }
419
420        // motifs stay on the model-free geometry, deduplicated against the curated facets as usual
421        let motifs: Vec<(String, Vec<String>)> =
422            crate::emergent::discover_motifs(&documents, MOTIF_TERMS, MOTIF_GROUPS)
423                .into_iter()
424                .filter(|(name, _)| !categories.iter().any(|(cat, _)| cat == name))
425                .collect();
426
427        // A curated ontology and a surface list are two separate arguments, so they can be from two separate
428        // corpora. That pairing produces a gazetteer matching nothing: no `entity/*` tags at all, an index that
429        // looks healthy, and queries that quietly answer with less than they should. Refusing beats indexing
430        // something hollow — the same argument as refusing an unknown category rather than returning an empty
431        // set. A surface list where NOTHING occurs is a mismatch, not a corpus with no entities.
432        if !surfaces.is_empty() {
433            let matcher = crate::emergent::MentionMatcher::new(surfaces);
434            if !documents.iter().any(|d| !matcher.present(d).is_empty()) {
435                return Err(Error::Empty(format!(
436                    "none of the {} registered surfaces occurs in any of the {} documents — the curated \
437                     ontology and the surface list look like they came from different corpora",
438                    surfaces.len(),
439                    documents.len()
440                )));
441            }
442        }
443
444        // the gazetteer is the spans the tagger observed — model-free from here on
445        let registrations: Vec<crate::artifact::Registration> = surfaces
446            .iter()
447            .map(|surface| crate::artifact::Registration {
448                surface: surface.clone(),
449                token: format!("entity/{}", crate::projector::slug(surface)),
450            })
451            .collect();
452        let corpus = Self::project_with(&documents, &categories, &registrations, &motifs);
453        Ok(SteelDb { corpus, categories, motifs, registrations, documents, min_gain })
454    }
455
456    /// **Ingest, following an existing artefact set.**
457    ///
458    /// Reads the vocabulary from `artifact_dir` instead of rediscovering it, so the result is reproducible and
459    /// no model is involved even if a model produced the vocabulary originally. This is the pairing that makes
460    /// `learn` worth running: the expensive, non-deterministic step happens once, and every run afterwards is
461    /// offline and identical.
462    pub fn ingest_using<I, S>(docs: I, artifact_dir: impl AsRef<Path>) -> Result<Self, Error>
463    where
464        I: IntoIterator<Item = S>,
465        S: AsRef<str>,
466    {
467        let set = crate::artifact::Artifacts::load(artifact_dir).map_err(Error::Artifact)?;
468        let documents: Vec<String> =
469            docs.into_iter().map(|d| d.as_ref().trim().to_string()).filter(|d| !d.is_empty()).collect();
470        if documents.is_empty() {
471            return Err(Error::Empty("no non-empty documents".into()));
472        }
473        let categories: Vec<(String, Vec<String>)> =
474            set.categories.into_iter().map(|c| (c.name, c.words)).collect();
475        // motifs come from the artefact rather than being rediscovered, so a reload cannot drift from the run
476        // that produced the files
477        let motifs: Vec<(String, Vec<String>)> =
478            set.motifs.into_iter().map(|m| (m.name, m.words)).collect();
479        let corpus = Self::project_with(&documents, &categories, &set.gazetteer, &motifs);
480        Ok(SteelDb {
481            corpus,
482            categories,
483            motifs,
484            registrations: set.gazetteer,
485            documents,
486            min_gain: Options::default().min_gain,
487        })
488    }
489
490    /// Write this database's vocabulary to an artefact directory.
491    ///
492    /// Only derived vocabulary is written — categories, mention surfaces, relation verbs. No document text, so
493    /// the directory is safe to commit alongside code.
494    pub fn save(&self, artifact_dir: impl AsRef<Path>) -> Result<(), Error> {
495        let categories = self
496            .categories
497            .iter()
498            .map(|(name, words)| crate::artifact::CategoryRecord {
499                name: name.clone(),
500                words: words.clone(),
501            })
502            .collect();
503        // Persist the gazetteer the corpus was PROJECTED with, not a re-mined one. When discovery was neural,
504        // re-mining here produced a different gazetteer and the reload drifted from the indexed corpus.
505        let registrations = self.registrations.clone();
506        let surfaces: Vec<String> = registrations.iter().map(|r| r.surface.clone()).collect();
507        let matcher = crate::emergent::MentionMatcher::new(&surfaces);
508        let mut relations: Vec<String> = Vec::new();
509        for doc in &self.documents {
510            for r in crate::emergent::relation_spans_with(doc, &matcher) {
511                if !relations.contains(&r.verb) {
512                    relations.push(r.verb);
513                }
514            }
515        }
516        let motifs = self
517            .motifs
518            .iter()
519            .map(|(name, words)| crate::artifact::CategoryRecord {
520                name: name.clone(),
521                words: words.clone(),
522            })
523            .collect();
524        crate::artifact::Artifacts::new("discovery", categories, registrations, relations)
525            .with_motifs(motifs)
526            .save(artifact_dir)
527            .map_err(Error::Artifact)
528    }
529
530    /// As [`SteelDb::save`], and additionally write a **finetuning set** derived from the corpus.
531    ///
532    /// The set is weak supervision: every span the discovered vocabulary can locate, labelled with the category
533    /// that claims it. It is what you would hand to a tagger finetune so the model learns to find these spans in
534    /// text it has not seen.
535    ///
536    /// Unlike the vocabulary files, this one **contains document text** — a span label is meaningless without
537    /// the words it points at. It is written to a `training/` subdirectory which
538    /// [`crate::artifact::Artifacts::save`] excludes with a `.gitignore`, and the manifest records that the
539    /// subdirectory is unsafe to publish.
540    pub fn save_with_training(&self, artifact_dir: impl AsRef<Path>) -> Result<usize, Error> {
541        // the gazetteer the corpus was projected with, so the training spans point at the vocabulary in use
542        let gazetteer: Vec<String> = self.registrations.iter().map(|r| r.surface.clone()).collect();
543        let mut jsonl = String::new();
544        for doc in &self.documents {
545            let mut spans: Vec<serde_json::Value> = Vec::new();
546            let mut push = |s: usize, e: usize, facet: &str| {
547                if let Some(surface) = doc.get(s..e) {
548                    spans.push(serde_json::json!({
549                        "start": s, "end": e, "facet": facet, "surface": surface,
550                    }));
551                }
552            };
553            for m in &gazetteer {
554                for (s, e) in crate::emergent::word_spans(doc, m) {
555                    push(s, e, "entity");
556                }
557            }
558            for (s, e, field) in crate::emergent::quantity_spans(doc) {
559                push(s, e, &format!("qty/{field}"));
560            }
561            for (s, e, tok) in crate::emergent::temporal_spans(doc) {
562                let _ = tok;
563                push(s, e, "time");
564            }
565            for (cat, words) in &self.categories {
566                for w in words {
567                    for (s, e) in crate::emergent::word_spans(doc, w) {
568                        push(s, e, cat);
569                    }
570                }
571            }
572            // overlapping labels would teach the tagger contradictory boundaries
573            spans.sort_by_key(|v| (v["start"].as_u64().unwrap_or(0), v["end"].as_u64().unwrap_or(0)));
574            let mut kept: Vec<serde_json::Value> = Vec::new();
575            let mut cursor = 0u64;
576            for sp in spans {
577                let (s, e) = (sp["start"].as_u64().unwrap_or(0), sp["end"].as_u64().unwrap_or(0));
578                if s >= cursor {
579                    cursor = e;
580                    kept.push(sp);
581                }
582            }
583            if kept.is_empty() {
584                continue; // a passage with no labels teaches nothing
585            }
586            let line = serde_json::json!({ "text": doc, "spans": kept });
587            jsonl.push_str(&line.to_string());
588            jsonl.push('\n');
589        }
590
591        let categories = self
592            .categories
593            .iter()
594            .map(|(name, words)| crate::artifact::CategoryRecord {
595                name: name.clone(),
596                words: words.clone(),
597            })
598            .collect();
599        let mut relations: Vec<String> = Vec::new();
600        for doc in &self.documents {
601            for r in crate::emergent::relation_spans(doc, &gazetteer) {
602                if !relations.contains(&r.verb) {
603                    relations.push(r.verb);
604                }
605            }
606        }
607        let set = crate::artifact::Artifacts::new(
608            "discovery",
609            categories,
610            self.registrations.clone(),
611            relations,
612        )
613        .with_motifs(
614            self.motifs
615                .iter()
616                .map(|(name, words)| crate::artifact::CategoryRecord {
617                    name: name.clone(),
618                    words: words.clone(),
619                })
620                .collect(),
621        )
622        .with_training(jsonl);
623        let n = set.training_examples;
624        set.save(artifact_dir).map_err(Error::Artifact)?;
625        Ok(n)
626    }
627
628    /// Index a directory of documents, discovering the vocabulary from what it finds.
629    pub fn open(dir: impl AsRef<Path>) -> Result<Self, Error> {
630        let dir = dir.as_ref();
631        let mut docs: Vec<String> = Vec::new();
632        for entry in std::fs::read_dir(dir)? {
633            let path = entry?.path();
634            if path.extension().map(|e| e == "md" || e == "txt").unwrap_or(false) {
635                if let Ok(text) = std::fs::read_to_string(&path) {
636                    docs.push(text);
637                }
638            }
639        }
640        if docs.is_empty() {
641            return Err(Error::Empty(format!("no .md or .txt files under {}", dir.display())));
642        }
643        Self::ingest(docs)
644    }
645
646    /// Mine mentions and register each under its canonical token, so discovery and an artefact reload agree.
647    fn registrations(docs: &[String]) -> Vec<crate::artifact::Registration> {
648        crate::emergent::mine_gazetteer(docs, 2)
649            .into_iter()
650            .map(|surface| {
651                let token = format!("entity/{}", crate::projector::slug(&surface));
652                crate::artifact::Registration { surface, token }
653            })
654            .collect()
655    }
656
657    /// Project documents into tagged situations, with the mention list supplied rather than mined — so an artefact set fully
658    /// determines the projection and a later run cannot drift from the recorded vocabulary.
659    fn project_with(
660        docs: &[String],
661        categories: &[(String, Vec<String>)],
662        registrations: &[crate::artifact::Registration],
663        motifs: &[(String, Vec<String>)],
664    ) -> Corpus {
665        let gazetteer: Vec<String> = registrations.iter().map(|r| r.surface.clone()).collect();
666        // surface -> canonical token, so a normalised registration is honoured rather than re-derived
667        let canonical: std::collections::HashMap<&str, &str> =
668            registrations.iter().map(|r| (r.surface.as_str(), r.token.as_str())).collect();
669        // One automaton over the whole gazetteer, reused for every document. Rebuilding it per document would
670        // reintroduce the O(documents x gazetteer) cost this exists to remove.
671        let matcher = crate::emergent::MentionMatcher::new(&gazetteer);
672
673        // Project documents in parallel. Each document is independent — the matcher and the canonical map are
674        // read-only and shared by reference — so the only shared work is that every worker may compute
675        // `entity/{slug(surface)}` for the same surface. That is a pure function of the surface, so two workers
676        // deriving it concurrently produce the same token with no coordination: the "registration" is
677        // contention-free by construction rather than by a lock. Results are collected per document and added to
678        // the index IN DOCUMENT ORDER, so situation ids stay deterministic regardless of how the work was split.
679        let projected: Vec<Projected> = Self::project_docs_parallel(docs, &matcher, &canonical, categories, motifs);
680
681        let mut corpus = Corpus::new_incremental("documents", vec!["document".into()], CorpusKind::Text);
682        for p in projected {
683            corpus.add_situation_polar(p.tags, p.display, p.numbers, p.beliefs);
684        }
685        corpus
686    }
687
688    /// Map every document to its projected situation, across at least six workers where the platform has
689    /// threads, sequentially on wasm (which has none). Order is preserved: `out[i]` is `docs[i]`.
690    fn project_docs_parallel(
691        docs: &[String],
692        matcher: &crate::emergent::MentionMatcher,
693        canonical: &std::collections::HashMap<&str, &str>,
694        categories: &[(String, Vec<String>)],
695        motifs: &[(String, Vec<String>)],
696    ) -> Vec<Projected> {
697        #[cfg(not(target_arch = "wasm32"))]
698        {
699            // At least six workers, as the workload warrants, capped so tiny corpora do not spawn needlessly.
700            let workers = std::thread::available_parallelism()
701                .map(|n| n.get())
702                .unwrap_or(6)
703                .max(6)
704                .min(docs.len().max(1));
705            if workers > 1 && docs.len() > 1 {
706                let chunk = docs.len().div_ceil(workers);
707                let mut chunks: Vec<Vec<Projected>> = Vec::new();
708                std::thread::scope(|scope| {
709                    let handles: Vec<_> = docs
710                        .chunks(chunk)
711                        .map(|slice| {
712                            scope.spawn(move || {
713                                slice
714                                    .iter()
715                                    .map(|d| Self::project_doc(d, matcher, canonical, categories, motifs))
716                                    .collect::<Vec<_>>()
717                            })
718                        })
719                        .collect();
720                    // joined in spawn order, which is document order, so the concatenation stays ordered
721                    for h in handles {
722                        chunks.push(h.join().expect("projection worker panicked"));
723                    }
724                });
725                return chunks.into_iter().flatten().collect();
726            }
727        }
728        docs.iter().map(|d| Self::project_doc(d, matcher, canonical, categories, motifs)).collect()
729    }
730
731    /// Project one document into the tags, display cell, numbers and beliefs of a single situation. Pure and
732    /// self-contained, which is what lets it run on any worker without shared mutable state.
733    fn project_doc(
734        doc: &str,
735        matcher: &crate::emergent::MentionMatcher,
736        canonical: &std::collections::HashMap<&str, &str>,
737        categories: &[(String, Vec<String>)],
738        motifs: &[(String, Vec<String>)],
739    ) -> Projected {
740        let mut tags: Vec<String> = Vec::new();
741        let mut numbers: Vec<(String, f64)> = Vec::new();
742        // Both cue sets, because the two projections had drifted apart here too: the browser copy knew
743        // "provisional" and "no longer" and this one did not. Keeping `belief_level` keeps the fourth
744        // polarity level — a claim that is both denied and hedged is neither a flat denial nor a hedge.
745        let lower = doc.to_lowercase();
746        let level = crate::dimensions::belief_level(
747            lower.contains("not permitted") || lower.contains("is not ") || lower.contains("no longer"),
748            lower.contains("under review") || lower.contains("may be") || lower.contains("provisional"),
749        );
750
751        for mention in matcher.present(doc) {
752            // use the registered token; slugging the surface here is what lost normalisation in v1
753            match canonical.get(mention.as_str()) {
754                Some(tok) => tags.push((*tok).to_string()),
755                None => tags.push(format!("entity/{}", crate::projector::slug(mention))),
756            }
757        }
758        for r in crate::emergent::relation_spans_with(doc, matcher) {
759            let verb = crate::projector::slug(&r.verb);
760            // The bare polarity tags say a relation of this kind happened in this situation. The
761            // argument-bound ones say WHO was on each side, which is what makes a reversed relation
762            // unmatchable rather than merely unranked: "Morty defeated Wallace" carries
763            // `rel/defeated/+/morty-shade`, and the reverse claim has no tag to hide inside.
764            tags.push(format!("rel/{verb}/+"));
765            tags.push(format!("rel/{verb}/+/{}", crate::projector::slug(&r.actor)));
766            tags.push(format!("rel/{verb}/-"));
767            tags.push(format!("rel/{verb}/-/{}", crate::projector::slug(&r.target)));
768        }
769        for (_, _, tok) in crate::emergent::temporal_spans(doc) {
770            tags.push(tok);
771        }
772        for (st, en, field) in crate::emergent::quantity_spans(doc) {
773            tags.push(format!("quantity/{field}"));
774            let digits: String = doc[st..en]
775                .chars()
776                .enumerate()
777                .take_while(|(i, c)| c.is_ascii_digit() || *c == '.' || (*i == 0 && *c == '-'))
778                .map(|(_, c)| c)
779                .collect();
780            if let Ok(v) = digits.parse::<f64>() {
781                numbers.push((field, v));
782            }
783        }
784        for (cat, terms) in categories {
785            for t in terms {
786                if crate::emergent::contains_term(doc, t) {
787                    tags.push(format!("{cat}/{}", crate::projector::slug(t)));
788                }
789            }
790        }
791        // A situation joins a motif through any member term, so two situations can share a theme without
792        // sharing a word — which is the point of the dimension.
793        for (name, terms) in motifs {
794            if terms.iter().any(|t| crate::emergent::contains_term(doc, t)) {
795                tags.push(format!("motif/{}", crate::projector::slug(name)));
796            }
797        }
798        tags.push(
799            match level {
800                l if l < 0.0 => "state/negated",
801                l if l < 1.0 => "state/hedged",
802                _ => "state/asserted",
803            }
804            .to_string(),
805        );
806
807        let beliefs: Vec<(String, f32)> = tags.iter().map(|t| (t.clone(), level)).collect();
808        let display = vec![doc.chars().take(160).collect::<String>()];
809        numbers.dedup_by(|a, b| a.0 == b.0);
810        Projected { tags, display, numbers, beliefs }
811    }
812
813    /// Run a query, or refuse it.
814    ///
815    /// Every tag is checked against the vocabulary before anything executes, so an unsupported query costs
816    /// nothing and comes back with alternatives.
817    pub fn query(&self, ikl: &str) -> Result<Answer, Refused> {
818        let report = self.corpus.linter().lint(ikl);
819        if let Some(fixed) = &report.repaired {
820            // The linter can repair unbalanced parentheses, and reports the repair. Answering the repaired
821            // expression would mean answering a question the caller did not ask — `(and a b` is not `(and a b)`
822            // until someone decides it is. So a query that needed repair is refused, with the repair offered as
823            // a suggestion.
824            return Err(Refused {
825                query: ikl.to_string(),
826                problems: vec![if fixed.trim().is_empty() {
827                    "unbalanced parentheses: a ')' with no matching '(' leaves nothing to run".into()
828                } else {
829                    format!("unbalanced parentheses; did you mean: {fixed}")
830                }],
831                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
832            });
833        }
834        if !report.ok {
835            return Err(Refused {
836                query: ikl.to_string(),
837                problems: report.errors.iter().map(|e| e.message.clone()).collect(),
838                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
839            });
840        }
841        match crate::tokenql::try_evaluate(self.corpus.index(), ikl) {
842            Ok(set) => Ok(Answer { ids: set.to_sorted(), micros: 0.0 }),
843            Err(e) => Err(Refused {
844                query: ikl.to_string(),
845                problems: vec![e.to_string()],
846                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
847            }),
848        }
849    }
850
851    /// Check a query without running it. Cheap, and the same check `query` performs.
852    pub fn check(&self, ikl: &str) -> Result<(), Refused> {
853        let report = self.corpus.linter().lint(ikl);
854        if let Some(fixed) = &report.repaired {
855            // The linter can repair unbalanced parentheses, and reports the repair. Answering the repaired
856            // expression would mean answering a question the caller did not ask — `(and a b` is not `(and a b)`
857            // until someone decides it is. So a query that needed repair is refused, with the repair offered as
858            // a suggestion.
859            return Err(Refused {
860                query: ikl.to_string(),
861                problems: vec![if fixed.trim().is_empty() {
862                    "unbalanced parentheses: a ')' with no matching '(' leaves nothing to run".into()
863                } else {
864                    format!("unbalanced parentheses; did you mean: {fixed}")
865                }],
866                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
867            });
868        }
869        if report.ok {
870            Ok(())
871        } else {
872            Err(Refused {
873                query: ikl.to_string(),
874                problems: report.errors.iter().map(|e| e.message.clone()).collect(),
875                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
876            })
877        }
878    }
879
880    /// The evidential bound on a tag across the whole corpus.
881    pub fn belief(&self, tag: &str) -> Interval {
882        let (belief, plausibility) = self.corpus.belief_interval(tag);
883        Interval { belief, plausibility }
884    }
885
886    /// Situations on a chain from `from` to `to` where each step shares at least `s` tags.
887    ///
888    /// Raising `s` demands more agreement per step, which is what stops a walk drifting somewhere unrelated.
889    pub fn s_path(&self, from: &str, to: &str, s: usize) -> Answer {
890        let set = self.corpus.index().s_path_tokens(from, to, s).map(|chain| {
891            let mut out = P::empty();
892            for tok in &chain {
893                out.or_inplace(&crate::tokenql::TokenStore::atom(self.corpus.index(), tok));
894            }
895            out
896        });
897        Answer { ids: set.map(|s| s.to_sorted()).unwrap_or_default(), micros: 0.0 }
898    }
899
900    /// Both readings of the incidence matrix, swept over the overlap threshold.
901    pub fn filtration(&self, max_s: usize) -> Vec<crate::programs::Level> {
902        crate::programs::s_filtration(self.corpus.index(), max_s, &Default::default(), 128)
903    }
904
905    /// The discovered categories and the words each claims.
906    pub fn categories(&self) -> Vec<Category<'_>> {
907        self.categories.iter().map(|(name, words)| Category { name, words }).collect()
908    }
909
910    /// The wildcard for every discovered category — the set of things you can ask about.
911    pub fn askable(&self) -> Vec<String> {
912        self.categories.iter().map(|(c, _)| format!("{c}/*")).collect()
913    }
914
915    /// Every tag in the index, grouped by its category and sorted within each group.
916    ///
917    /// Sorted because the engine is otherwise deterministic and a caller should not have to defend against
918    /// index iteration order — two runs over the same documents return byte-identical output.
919    pub fn tags(&self) -> BTreeMap<String, Vec<String>> {
920        let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
921        for tag in self.corpus.index().tokens() {
922            let stem = tag.split('/').next().unwrap_or("").to_string();
923            out.entry(stem).or_default().push(tag.clone());
924        }
925        for v in out.values_mut() {
926            v.sort();
927            v.dedup();
928        }
929        out
930    }
931
932    /// The document behind a situation id.
933    ///
934    /// Without this an [`Answer`] is a list of integers. Every result is traceable back to the text that
935    /// produced it, which is what makes an answer checkable rather than merely plausible.
936    pub fn text(&self, situation: u32) -> Option<&str> {
937        self.documents.get(situation as usize).map(|s| s.as_str())
938    }
939
940    /// The documents an answer refers to, in id order.
941    pub fn resolve<'a>(&'a self, answer: &'a Answer) -> impl Iterator<Item = (u32, &'a str)> + 'a {
942        answer.ids().iter().filter_map(move |id| self.text(*id).map(|t| (*id, t)))
943    }
944
945    /// How many situations are indexed.
946    pub fn len(&self) -> usize {
947        self.documents.len()
948    }
949    pub fn is_empty(&self) -> bool {
950        self.documents.is_empty()
951    }
952    /// The documents as given, for tracing a result back to its source.
953    pub fn documents(&self) -> &[String] {
954        &self.documents
955    }
956
957    // ── internals used by `learn`, which needs to re-run the same gate ──
958
959    /// A vocabulary spec matching the currently accepted categories.
960    pub(crate) fn spec_snapshot(&self) -> crate::vocabulary::VocabularySpace {
961        crate::vocabulary::VocabularySpace {
962            version: 1,
963            corpus: "documents".into(),
964            entity_facets: self
965                .categories
966                .iter()
967                .map(|(name, words)| crate::vocabulary::EntityFacet {
968                    name: name.clone(),
969                    parent: None,
970                    description: String::new(),
971                    examples: words.clone(),
972                    structural: false,
973                })
974                .collect(),
975            relation_facets: Vec::new(),
976            gazetteer: Vec::new(),
977            metrics: None,
978        }
979    }
980
981    /// Hand over the index. Used by the WebAssembly layer so the browser demo runs the same projection the
982    /// library does, rather than a second implementation that has to be kept in step by hand.
983    #[cfg(feature = "wasm")]
984    pub(crate) fn into_corpus(self) -> Corpus {
985        self.corpus
986    }
987
988    pub(crate) fn min_gain(&self) -> f64 {
989        self.min_gain
990    }
991
992    pub(crate) fn push_category(&mut self, name: String, words: Vec<String>) {
993        self.categories.push((name, words));
994    }
995
996    /// Rebuild the index. Required after adopting a category, because a new category changes what every
997    /// document projects to — leaving the old index would answer with a vocabulary the spec no longer matches.
998    pub(crate) fn reproject(&mut self) {
999        // reuse the registrations already in hand — adopting a category changes category tags, not the entity
1000        // gazetteer, and re-mining would discard a neural gazetteer in favour of a lexical one
1001        self.corpus = Self::project_with(&self.documents, &self.categories, &self.registrations, &self.motifs);
1002    }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008
1009    fn corpus() -> Vec<String> {
1010        [
1011            "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational in 2025.",
1012            "Bea Strike defeated Iris Draco at Ecruteak City during the Indigo Invitational in 2025.",
1013            "A habitat survey recorded Aggron near Sootopolis City at an elevation of 1082 m.",
1014            "A habitat survey recorded Salamence near Sootopolis City at an elevation of 2369 m.",
1015            "Milotic is not permitted in Series 1 play for the 2025 season.",
1016            "Metagross is permitted in Series 4 play for the 2026 season.",
1017        ]
1018        .iter()
1019        .map(|s| s.to_string())
1020        .collect()
1021    }
1022
1023    #[test]
1024    fn three_lines_to_a_working_database() {
1025        let db = SteelDb::ingest(corpus()).expect("index");
1026        assert_eq!(db.len(), 6);
1027        assert!(!db.categories().is_empty(), "should discover at least one category");
1028    }
1029
1030    #[test]
1031    fn an_unsupported_query_is_refused_with_alternatives() {
1032        let db = SteelDb::ingest(corpus()).unwrap();
1033        let err = db.query("gene/brca1").expect_err("must refuse a category the data lacks");
1034        assert!(!err.alternatives.is_empty(), "a refusal must say what does exist");
1035        let shown = err.to_string();
1036        assert!(shown.contains("refused"), "{shown}");
1037        assert!(shown.contains("available"), "{shown}");
1038    }
1039
1040    #[test]
1041    fn a_supported_query_returns_a_complete_set() {
1042        let db = SteelDb::ingest(corpus()).unwrap();
1043        let cat = db.categories()[0].name.to_string();
1044        let answer = db.query(&format!("{cat}/*")).expect("a discovered category must be queryable");
1045        assert!(!answer.is_empty());
1046        // complete, not sampled: every id is a real situation
1047        assert!(answer.ids().iter().all(|id| (*id as usize) < db.len()));
1048        // and iterable directly
1049        assert_eq!(answer.ids().len(), (&answer).into_iter().count());
1050    }
1051
1052    #[test]
1053    fn negation_narrows_rather_than_widens() {
1054        let db = SteelDb::ingest(corpus()).unwrap();
1055        let cat = db.categories()[0].name.to_string();
1056        let all = db.query(&format!("{cat}/*")).unwrap().len();
1057        let some = db.query(&format!("(and {cat}/* (not state/negated))")).unwrap().len();
1058        assert!(some <= all, "excluding something cannot return more: {some} vs {all}");
1059    }
1060
1061    #[test]
1062    fn belief_separates_asserted_from_negated() {
1063        let db = SteelDb::ingest(corpus()).unwrap();
1064        let asserted = db.belief("state/asserted");
1065        let negated = db.belief("state/negated");
1066        assert!(asserted.belief > negated.belief, "{asserted} vs {negated}");
1067        // and the interval exposes its own meaning rather than making the caller compare floats
1068        assert!(asserted.ignorance() >= 0.0);
1069        assert!(db.belief("state/nonexistent").is_unknown(), "an absent tag is unknown, not refuted");
1070    }
1071
1072    #[test]
1073    fn check_costs_nothing_and_agrees_with_query() {
1074        let db = SteelDb::ingest(corpus()).unwrap();
1075        assert!(db.check("gene/brca1").is_err());
1076        assert!(db.query("gene/brca1").is_err());
1077        let cat = db.categories()[0].name.to_string();
1078        assert!(db.check(&format!("{cat}/*")).is_ok());
1079    }
1080
1081    #[test]
1082    fn the_filtration_thins_as_the_threshold_rises() {
1083        let db = SteelDb::ingest(corpus()).unwrap();
1084        let levels = db.filtration(4);
1085        assert_eq!(levels.len(), 4);
1086        // more required agreement can only remove edges
1087        for w in levels.windows(2) {
1088            assert!(w[1].primal.edges <= w[0].primal.edges, "edges must not grow with s");
1089            assert!(w[1].dual.edges <= w[0].dual.edges);
1090        }
1091    }
1092
1093    #[test]
1094    fn empty_input_is_an_error_not_an_empty_database() {
1095        assert!(matches!(SteelDb::ingest(Vec::<String>::new()), Err(Error::Empty(_))));
1096        assert!(matches!(SteelDb::ingest(vec!["   ", ""]), Err(Error::Empty(_))));
1097    }
1098
1099    #[test]
1100    fn artefacts_make_a_later_ingest_reproducible() {
1101        // The pairing that justifies `learn`: the vocabulary is recorded once, and a later run reproduces it
1102        // exactly without a model.
1103        let dir = std::env::temp_dir().join(format!("hsdb_api_repro_{}", std::process::id()));
1104        let _ = std::fs::remove_dir_all(&dir);
1105
1106        let first = SteelDb::ingest(corpus()).unwrap();
1107        first.save(&dir).unwrap();
1108        let cat = first.categories()[0].name.to_string();
1109        let expected = first.query(&format!("{cat}/*")).unwrap().len();
1110
1111        let second = SteelDb::ingest_using(corpus(), &dir).unwrap();
1112        assert_eq!(
1113            second.categories().iter().map(|c| c.name.to_string()).collect::<Vec<_>>(),
1114            first.categories().iter().map(|c| c.name.to_string()).collect::<Vec<_>>(),
1115            "the recorded vocabulary must be reproduced exactly"
1116        );
1117        assert_eq!(second.query(&format!("{cat}/*")).unwrap().len(), expected, "and answer identically");
1118        let _ = std::fs::remove_dir_all(&dir);
1119    }
1120
1121    #[test]
1122    fn saved_artefacts_contain_no_document_text() {
1123        // Leakage guard at the API level: what `save` writes must be vocabulary, never the corpus.
1124        let dir = std::env::temp_dir().join(format!("hsdb_api_leak_{}", std::process::id()));
1125        let _ = std::fs::remove_dir_all(&dir);
1126        let db = SteelDb::ingest(corpus()).unwrap();
1127        db.save(&dir).unwrap();
1128
1129        for entry in std::fs::read_dir(&dir).unwrap() {
1130            let p = entry.unwrap().path();
1131            let text = std::fs::read_to_string(&p).unwrap();
1132            for doc in corpus() {
1133                assert!(
1134                    !text.contains(doc.as_str()),
1135                    "{} contains a whole document",
1136                    p.display()
1137                );
1138                // a distinctive multi-word fragment is enough to prove a copy
1139                let frag: String = doc.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
1140                assert!(!text.contains(&frag), "{} contains the fragment {frag:?}", p.display());
1141            }
1142        }
1143        let _ = std::fs::remove_dir_all(&dir);
1144    }
1145
1146    #[test]
1147    fn ingesting_against_a_missing_artefact_set_is_an_error() {
1148        let missing = std::env::temp_dir().join("hsdb_definitely_absent_dir");
1149        let _ = std::fs::remove_dir_all(&missing);
1150        assert!(matches!(
1151            SteelDb::ingest_using(corpus(), &missing),
1152            Err(Error::Artifact(_))
1153        ));
1154    }
1155
1156    #[test]
1157    fn a_finetuning_set_is_written_separately_from_the_vocabulary() {
1158        let dir = std::env::temp_dir().join(format!("hsdb_api_train_{}", std::process::id()));
1159        let _ = std::fs::remove_dir_all(&dir);
1160        let db = SteelDb::ingest(corpus()).unwrap();
1161        let n = db.save_with_training(&dir).unwrap();
1162        assert!(n > 0, "the corpus should yield labelled passages");
1163
1164        // the committable parts still contain no document text, even though a training set exists
1165        for f in ["manifest.json", "vocabulary.json", "gazetteer.json", "relations.json"] {
1166            let text = std::fs::read_to_string(dir.join(f)).unwrap();
1167            for doc in corpus() {
1168                let frag: String = doc.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
1169                assert!(!text.contains(&frag), "{f} leaked: {frag:?}");
1170            }
1171        }
1172        // and the training file does carry text, which is the point of keeping it apart
1173        let train = std::fs::read_to_string(dir.join("training").join("spans.jsonl")).unwrap();
1174        assert!(train.contains("Morty Shade"), "the finetuning set needs the words");
1175        assert!(dir.join(".gitignore").exists(), "and must be excluded from commits");
1176        let _ = std::fs::remove_dir_all(&dir);
1177    }
1178
1179    #[test]
1180    fn training_spans_do_not_overlap() {
1181        // Overlapping labels would teach a tagger contradictory boundaries for the same characters.
1182        let dir = std::env::temp_dir().join(format!("hsdb_api_ovl_{}", std::process::id()));
1183        let _ = std::fs::remove_dir_all(&dir);
1184        SteelDb::ingest(corpus()).unwrap().save_with_training(&dir).unwrap();
1185        let train = std::fs::read_to_string(dir.join("training").join("spans.jsonl")).unwrap();
1186        for line in train.lines().filter(|l| !l.trim().is_empty()) {
1187            let v: serde_json::Value = serde_json::from_str(line).unwrap();
1188            let spans = v["spans"].as_array().unwrap();
1189            let mut last_end = 0u64;
1190            for sp in spans {
1191                let s = sp["start"].as_u64().unwrap();
1192                let e = sp["end"].as_u64().unwrap();
1193                assert!(s >= last_end, "span {s}..{e} overlaps the previous one ending at {last_end}");
1194                assert!(e > s, "empty span");
1195                last_end = e;
1196            }
1197        }
1198        let _ = std::fs::remove_dir_all(&dir);
1199    }
1200
1201    #[test]
1202    fn registered_variants_still_merge_after_an_artefact_reload() {
1203        // The v1 bug: the artefact recorded only surfaces, so a reload re-derived the token by slugging the raw
1204        // text and two variants of one entity stopped sharing a token. Registration NORMALISES, and that has to
1205        // survive the round trip or ingest is not really following the artefacts.
1206        let dir = std::env::temp_dir().join(format!("hsdb_api_canon_{}", std::process::id()));
1207        let _ = std::fs::remove_dir_all(&dir);
1208
1209        let db = SteelDb::ingest(corpus()).unwrap();
1210        db.save(&dir).unwrap();
1211
1212        let set = crate::artifact::Artifacts::load(&dir).unwrap();
1213        assert!(!set.gazetteer.is_empty(), "the corpus should register some mentions");
1214        for r in &set.gazetteer {
1215            assert!(!r.token.is_empty(), "every registration needs a canonical token");
1216            assert!(r.token.contains('/'), "a token is facet-qualified: {}", r.token);
1217        }
1218
1219        // reloading must reproduce the same entity tags, not re-derive different ones
1220        let reloaded = SteelDb::ingest_using(corpus(), &dir).unwrap();
1221        let tags_before: Vec<String> =
1222            db.tags().get("entity").cloned().unwrap_or_default();
1223        let tags_after: Vec<String> =
1224            reloaded.tags().get("entity").cloned().unwrap_or_default();
1225        assert_eq!(tags_before, tags_after, "entity tags must survive the round trip unchanged");
1226        let _ = std::fs::remove_dir_all(&dir);
1227    }
1228
1229    #[test]
1230    fn parallel_projection_preserves_document_order() {
1231        // Projection runs across several worker threads, so a chunk added out of order would misalign every
1232        // situation id with its document. Enough documents to force more than one chunk, then check that
1233        // situation i still shows document i.
1234        let docs: Vec<String> = (0..40)
1235            .map(|i| format!("Trainer{i} Shade defeated Rival{i} Gale at Ecruteak City in 2025."))
1236            .collect();
1237        let db = SteelDb::ingest(docs.clone()).expect("ingest");
1238        assert_eq!(db.len(), 40);
1239        for (i, doc) in docs.iter().enumerate() {
1240            let shown = db.text(i as u32).expect("every situation resolves");
1241            let head: String = doc.chars().take(20).collect();
1242            assert!(shown.starts_with(&head), "situation {i} shows {shown:?}, not document {i} ({head:?})");
1243        }
1244    }
1245
1246    #[test]
1247    fn ingest_is_deterministic_under_parallelism() {
1248        // Two runs over the same documents must be byte-identical, or the parallel split has leaked into the
1249        // result. 40 documents guarantees multiple worker chunks.
1250        let docs: Vec<String> = (0..40)
1251            .map(|i| {
1252                if i % 2 == 0 {
1253                    format!("Trainer{i} defeated Rival{i} at Ecruteak City in 2025.")
1254                } else {
1255                    format!("A survey recorded Aggron near Sootopolis City at {} m.", 600 + i)
1256                }
1257            })
1258            .collect();
1259        let a = SteelDb::ingest(docs.clone()).expect("a");
1260        let b = SteelDb::ingest(docs).expect("b");
1261        assert_eq!(a.tags(), b.tags(), "parallel ingest is not deterministic");
1262        assert_eq!(a.askable(), b.askable());
1263    }
1264
1265    #[test]
1266    fn an_artefact_reload_indexes_identically_to_discovery() {
1267        // The promise `ingest_using` makes is that the expensive step happens once and every run afterwards is
1268        // the same. That only holds if the artefact captures everything the projection needs — motifs were
1269        // missing in schema 2, so a reload silently produced no motif/* tokens and the same documents indexed
1270        // two different ways depending on which path they came through.
1271        let docs = corpus();
1272        let db = SteelDb::ingest(docs.clone()).expect("ingest");
1273
1274        let dir = std::env::temp_dir().join(format!("steeldb-reload-{}", std::process::id()));
1275        let _ = std::fs::remove_dir_all(&dir);
1276        db.save(&dir).expect("save");
1277        let reloaded = SteelDb::ingest_using(docs, &dir).expect("reload");
1278
1279        assert_eq!(db.tags(), reloaded.tags(), "an artefact reload must index identically");
1280        assert_eq!(db.askable(), reloaded.askable());
1281        let _ = std::fs::remove_dir_all(&dir);
1282    }
1283
1284    #[test]
1285    fn a_relation_records_who_was_on_each_side() {
1286        // Bare polarity says a defeat happened; it does not say who won. Without the argument-bound tokens the
1287        // reverse claim matches exactly the same set, which is the one thing a directional relation must not do.
1288        let db = SteelDb::ingest(corpus()).expect("ingest");
1289        let rel = db.tags().get("rel").cloned().unwrap_or_default();
1290
1291        let bound: Vec<&String> = rel.iter().filter(|t| t.matches('/').count() == 3).collect();
1292        assert!(!bound.is_empty(), "no argument-bound relation tokens: {rel:?}");
1293        assert!(
1294            bound.iter().any(|t| t.contains("/+/") ) && bound.iter().any(|t| t.contains("/-/")),
1295            "both sides must be recorded: {bound:?}"
1296        );
1297        // and the mention must survive whole — `shade` instead of `morty-shade` loses the person
1298        assert!(
1299            rel.iter().any(|t| t.ends_with("morty-shade")),
1300            "a name opening a sentence must not be truncated: {rel:?}"
1301        );
1302    }
1303
1304    #[test]
1305    fn a_refusal_stays_readable_rather_than_running_off_the_line() {
1306        // This is the message a user sees at the moment they are confused, so width matters. It listed every
1307        // dimension in the corpus on one line, which came to 122 characters and wrapped wherever the terminal
1308        // happened to end.
1309        let db = SteelDb::ingest(corpus()).unwrap();
1310        let shown = db.query("gene/brca1").unwrap_err().to_string();
1311
1312        for line in shown.lines() {
1313            assert!(line.chars().count() <= 78, "line is {} chars: {line:?}", line.chars().count());
1314        }
1315        // wrapping must not drop or mangle an item
1316        assert!(shown.contains("defeated"), "{shown}");
1317        assert!(!shown.contains(",,") && !shown.contains(" ,"), "mangled list: {shown}");
1318        // a broken line keeps its comma, so the list does not read as if an entry were missing
1319        for line in shown.lines() {
1320            let t = line.trim_end();
1321            if t.ends_with("defeated") || t.ends_with("elevation") {
1322                panic!("a wrapped list line lost its comma: {shown}");
1323            }
1324        }
1325    }
1326
1327    #[test]
1328    fn wrapping_leaves_a_short_message_untouched() {
1329        assert_eq!(wrap_indented("a, b", 70, "  "), "a, b");
1330        assert_eq!(wrap_indented("", 70, "  "), "");
1331        assert_eq!(wrap_indented("single", 2, "  "), "single", "one oversized item cannot be split");
1332    }
1333
1334    #[test]
1335    fn unbalanced_parentheses_are_refused_rather_than_repaired_or_crashed() {
1336        // Two faults found by stressing the published crate. `query(")")` PANICKED: the linter repairs
1337        // parentheses and reported the query clean, then evaluation parsed the raw string and hit an
1338        // `expect("unbalanced )")`. In the wasm build a panic takes the whole module down.
1339        //
1340        // The second fault was quieter and worse. Because the linter repairs, `(and a b` was reported clean and
1341        // then evaluated as `(and a b)` — answering a question the caller had not asked. A repair is a
1342        // suggestion, not a licence to rewrite.
1343        let db = SteelDb::ingest(corpus()).unwrap();
1344
1345        for q in [")", "(", "(and", "))))", "((((", "(and a b", "(or (not x"] {
1346            let e = db
1347                .query(q)
1348                .err()
1349                .unwrap_or_else(|| panic!("{q:?} was answered instead of refused"));
1350            assert!(
1351                e.problems.iter().any(|p| p.contains("unbalanced")),
1352                "{q:?} refused for the wrong reason: {:?}",
1353                e.problems
1354            );
1355            // check() must agree, or one path answers what the other rejects
1356            assert!(db.check(q).is_err(), "check accepted {q:?} while query refused it");
1357        }
1358
1359        // a balanced expression is unaffected
1360        assert!(db.query("state/asserted").is_ok());
1361        assert!(db.query("(not state/negated)").is_ok());
1362    }
1363
1364    #[test]
1365    fn the_parser_never_panics_on_hostile_input() {
1366        // `tokenql::parse` is public, so it is reachable with any string at all.
1367        for q in [")", "((", "()", "\"", "\"unclosed", "(\")\")", "\0", "(((((((((((((((((((("] {
1368            let _ = crate::tokenql::parse(q);
1369        }
1370    }
1371
1372    #[test]
1373    fn a_curated_ontology_paired_with_the_wrong_surfaces_is_refused() {
1374        // `ingest_curated` takes the curated facets and the observed surfaces as two arguments, so nothing stops
1375        // them coming from different corpora. That combination yields a gazetteer that matches nothing: no
1376        // entity/* tags, an index that looks healthy, and queries quietly answering with less than they should.
1377        use crate::learn::{Candidate, Proposal};
1378        let curated = Proposal {
1379            source: "test".into(),
1380            candidates: vec![Candidate {
1381                name: "ruling".into(),
1382                words: vec!["permitted".into(), "season".into()],
1383                rationale: String::new(),
1384            }],
1385        };
1386
1387        // surfaces from a corpus that shares nothing with these documents
1388        let wrong = ["Zzyzx Consolidated".to_string(), "Qqqq Holdings".to_string()];
1389        let err = SteelDb::ingest_curated(corpus(), &curated, &wrong)
1390            .err()
1391            .expect("a surface list matching nothing must be refused");
1392        assert!(format!("{err}").contains("different corpora"), "{err}");
1393
1394        // the right surfaces index fine
1395        let right: Vec<String> =
1396            crate::emergent::mine_gazetteer(&corpus(), 2).into_iter().collect();
1397        if !right.is_empty() {
1398            let db = SteelDb::ingest_curated(corpus(), &curated, &right).expect("matching surfaces");
1399            assert_eq!(db.len(), corpus().len());
1400        }
1401
1402        // and an intentionally empty surface list is allowed: a corpus may genuinely have no registered mentions
1403        SteelDb::ingest_curated(corpus(), &curated, &[]).expect("an empty gazetteer is legitimate");
1404    }
1405}