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        // the gazetteer is the spans the tagger observed — model-free from here on
428        let registrations: Vec<crate::artifact::Registration> = surfaces
429            .iter()
430            .map(|surface| crate::artifact::Registration {
431                surface: surface.clone(),
432                token: format!("entity/{}", crate::projector::slug(surface)),
433            })
434            .collect();
435        let corpus = Self::project_with(&documents, &categories, &registrations, &motifs);
436        Ok(SteelDb { corpus, categories, motifs, registrations, documents, min_gain })
437    }
438
439    /// **Ingest, following an existing artefact set.**
440    ///
441    /// Reads the vocabulary from `artifact_dir` instead of rediscovering it, so the result is reproducible and
442    /// no model is involved even if a model produced the vocabulary originally. This is the pairing that makes
443    /// `learn` worth running: the expensive, non-deterministic step happens once, and every run afterwards is
444    /// offline and identical.
445    pub fn ingest_using<I, S>(docs: I, artifact_dir: impl AsRef<Path>) -> Result<Self, Error>
446    where
447        I: IntoIterator<Item = S>,
448        S: AsRef<str>,
449    {
450        let set = crate::artifact::Artifacts::load(artifact_dir).map_err(Error::Artifact)?;
451        let documents: Vec<String> =
452            docs.into_iter().map(|d| d.as_ref().trim().to_string()).filter(|d| !d.is_empty()).collect();
453        if documents.is_empty() {
454            return Err(Error::Empty("no non-empty documents".into()));
455        }
456        let categories: Vec<(String, Vec<String>)> =
457            set.categories.into_iter().map(|c| (c.name, c.words)).collect();
458        // motifs come from the artefact rather than being rediscovered, so a reload cannot drift from the run
459        // that produced the files
460        let motifs: Vec<(String, Vec<String>)> =
461            set.motifs.into_iter().map(|m| (m.name, m.words)).collect();
462        let corpus = Self::project_with(&documents, &categories, &set.gazetteer, &motifs);
463        Ok(SteelDb {
464            corpus,
465            categories,
466            motifs,
467            registrations: set.gazetteer,
468            documents,
469            min_gain: Options::default().min_gain,
470        })
471    }
472
473    /// Write this database's vocabulary to an artefact directory.
474    ///
475    /// Only derived vocabulary is written — categories, mention surfaces, relation verbs. No document text, so
476    /// the directory is safe to commit alongside code.
477    pub fn save(&self, artifact_dir: impl AsRef<Path>) -> Result<(), Error> {
478        let categories = self
479            .categories
480            .iter()
481            .map(|(name, words)| crate::artifact::CategoryRecord {
482                name: name.clone(),
483                words: words.clone(),
484            })
485            .collect();
486        // Persist the gazetteer the corpus was PROJECTED with, not a re-mined one. When discovery was neural,
487        // re-mining here produced a different gazetteer and the reload drifted from the indexed corpus.
488        let registrations = self.registrations.clone();
489        let surfaces: Vec<String> = registrations.iter().map(|r| r.surface.clone()).collect();
490        let matcher = crate::emergent::MentionMatcher::new(&surfaces);
491        let mut relations: Vec<String> = Vec::new();
492        for doc in &self.documents {
493            for r in crate::emergent::relation_spans_with(doc, &matcher) {
494                if !relations.contains(&r.verb) {
495                    relations.push(r.verb);
496                }
497            }
498        }
499        let motifs = self
500            .motifs
501            .iter()
502            .map(|(name, words)| crate::artifact::CategoryRecord {
503                name: name.clone(),
504                words: words.clone(),
505            })
506            .collect();
507        crate::artifact::Artifacts::new("discovery", categories, registrations, relations)
508            .with_motifs(motifs)
509            .save(artifact_dir)
510            .map_err(Error::Artifact)
511    }
512
513    /// As [`SteelDb::save`], and additionally write a **finetuning set** derived from the corpus.
514    ///
515    /// The set is weak supervision: every span the discovered vocabulary can locate, labelled with the category
516    /// that claims it. It is what you would hand to a tagger finetune so the model learns to find these spans in
517    /// text it has not seen.
518    ///
519    /// Unlike the vocabulary files, this one **contains document text** — a span label is meaningless without
520    /// the words it points at. It is written to a `training/` subdirectory which
521    /// [`crate::artifact::Artifacts::save`] excludes with a `.gitignore`, and the manifest records that the
522    /// subdirectory is unsafe to publish.
523    pub fn save_with_training(&self, artifact_dir: impl AsRef<Path>) -> Result<usize, Error> {
524        // the gazetteer the corpus was projected with, so the training spans point at the vocabulary in use
525        let gazetteer: Vec<String> = self.registrations.iter().map(|r| r.surface.clone()).collect();
526        let mut jsonl = String::new();
527        for doc in &self.documents {
528            let mut spans: Vec<serde_json::Value> = Vec::new();
529            let mut push = |s: usize, e: usize, facet: &str| {
530                if let Some(surface) = doc.get(s..e) {
531                    spans.push(serde_json::json!({
532                        "start": s, "end": e, "facet": facet, "surface": surface,
533                    }));
534                }
535            };
536            for m in &gazetteer {
537                for (s, e) in crate::emergent::word_spans(doc, m) {
538                    push(s, e, "entity");
539                }
540            }
541            for (s, e, field) in crate::emergent::quantity_spans(doc) {
542                push(s, e, &format!("qty/{field}"));
543            }
544            for (s, e, tok) in crate::emergent::temporal_spans(doc) {
545                let _ = tok;
546                push(s, e, "time");
547            }
548            for (cat, words) in &self.categories {
549                for w in words {
550                    for (s, e) in crate::emergent::word_spans(doc, w) {
551                        push(s, e, cat);
552                    }
553                }
554            }
555            // overlapping labels would teach the tagger contradictory boundaries
556            spans.sort_by_key(|v| (v["start"].as_u64().unwrap_or(0), v["end"].as_u64().unwrap_or(0)));
557            let mut kept: Vec<serde_json::Value> = Vec::new();
558            let mut cursor = 0u64;
559            for sp in spans {
560                let (s, e) = (sp["start"].as_u64().unwrap_or(0), sp["end"].as_u64().unwrap_or(0));
561                if s >= cursor {
562                    cursor = e;
563                    kept.push(sp);
564                }
565            }
566            if kept.is_empty() {
567                continue; // a passage with no labels teaches nothing
568            }
569            let line = serde_json::json!({ "text": doc, "spans": kept });
570            jsonl.push_str(&line.to_string());
571            jsonl.push('\n');
572        }
573
574        let categories = self
575            .categories
576            .iter()
577            .map(|(name, words)| crate::artifact::CategoryRecord {
578                name: name.clone(),
579                words: words.clone(),
580            })
581            .collect();
582        let mut relations: Vec<String> = Vec::new();
583        for doc in &self.documents {
584            for r in crate::emergent::relation_spans(doc, &gazetteer) {
585                if !relations.contains(&r.verb) {
586                    relations.push(r.verb);
587                }
588            }
589        }
590        let set = crate::artifact::Artifacts::new(
591            "discovery",
592            categories,
593            self.registrations.clone(),
594            relations,
595        )
596        .with_motifs(
597            self.motifs
598                .iter()
599                .map(|(name, words)| crate::artifact::CategoryRecord {
600                    name: name.clone(),
601                    words: words.clone(),
602                })
603                .collect(),
604        )
605        .with_training(jsonl);
606        let n = set.training_examples;
607        set.save(artifact_dir).map_err(Error::Artifact)?;
608        Ok(n)
609    }
610
611    /// Index a directory of documents, discovering the vocabulary from what it finds.
612    pub fn open(dir: impl AsRef<Path>) -> Result<Self, Error> {
613        let dir = dir.as_ref();
614        let mut docs: Vec<String> = Vec::new();
615        for entry in std::fs::read_dir(dir)? {
616            let path = entry?.path();
617            if path.extension().map(|e| e == "md" || e == "txt").unwrap_or(false) {
618                if let Ok(text) = std::fs::read_to_string(&path) {
619                    docs.push(text);
620                }
621            }
622        }
623        if docs.is_empty() {
624            return Err(Error::Empty(format!("no .md or .txt files under {}", dir.display())));
625        }
626        Self::ingest(docs)
627    }
628
629    /// Mine mentions and register each under its canonical token, so discovery and an artefact reload agree.
630    fn registrations(docs: &[String]) -> Vec<crate::artifact::Registration> {
631        crate::emergent::mine_gazetteer(docs, 2)
632            .into_iter()
633            .map(|surface| {
634                let token = format!("entity/{}", crate::projector::slug(&surface));
635                crate::artifact::Registration { surface, token }
636            })
637            .collect()
638    }
639
640    /// Project documents into tagged situations, with the mention list supplied rather than mined — so an artefact set fully
641    /// determines the projection and a later run cannot drift from the recorded vocabulary.
642    fn project_with(
643        docs: &[String],
644        categories: &[(String, Vec<String>)],
645        registrations: &[crate::artifact::Registration],
646        motifs: &[(String, Vec<String>)],
647    ) -> Corpus {
648        let gazetteer: Vec<String> = registrations.iter().map(|r| r.surface.clone()).collect();
649        // surface -> canonical token, so a normalised registration is honoured rather than re-derived
650        let canonical: std::collections::HashMap<&str, &str> =
651            registrations.iter().map(|r| (r.surface.as_str(), r.token.as_str())).collect();
652        // One automaton over the whole gazetteer, reused for every document. Rebuilding it per document would
653        // reintroduce the O(documents x gazetteer) cost this exists to remove.
654        let matcher = crate::emergent::MentionMatcher::new(&gazetteer);
655
656        // Project documents in parallel. Each document is independent — the matcher and the canonical map are
657        // read-only and shared by reference — so the only shared work is that every worker may compute
658        // `entity/{slug(surface)}` for the same surface. That is a pure function of the surface, so two workers
659        // deriving it concurrently produce the same token with no coordination: the "registration" is
660        // contention-free by construction rather than by a lock. Results are collected per document and added to
661        // the index IN DOCUMENT ORDER, so situation ids stay deterministic regardless of how the work was split.
662        let projected: Vec<Projected> = Self::project_docs_parallel(docs, &matcher, &canonical, categories, motifs);
663
664        let mut corpus = Corpus::new_incremental("documents", vec!["document".into()], CorpusKind::Text);
665        for p in projected {
666            corpus.add_situation_polar(p.tags, p.display, p.numbers, p.beliefs);
667        }
668        corpus
669    }
670
671    /// Map every document to its projected situation, across at least six workers where the platform has
672    /// threads, sequentially on wasm (which has none). Order is preserved: `out[i]` is `docs[i]`.
673    fn project_docs_parallel(
674        docs: &[String],
675        matcher: &crate::emergent::MentionMatcher,
676        canonical: &std::collections::HashMap<&str, &str>,
677        categories: &[(String, Vec<String>)],
678        motifs: &[(String, Vec<String>)],
679    ) -> Vec<Projected> {
680        #[cfg(not(target_arch = "wasm32"))]
681        {
682            // At least six workers, as the workload warrants, capped so tiny corpora do not spawn needlessly.
683            let workers = std::thread::available_parallelism()
684                .map(|n| n.get())
685                .unwrap_or(6)
686                .max(6)
687                .min(docs.len().max(1));
688            if workers > 1 && docs.len() > 1 {
689                let chunk = docs.len().div_ceil(workers);
690                let mut chunks: Vec<Vec<Projected>> = Vec::new();
691                std::thread::scope(|scope| {
692                    let handles: Vec<_> = docs
693                        .chunks(chunk)
694                        .map(|slice| {
695                            scope.spawn(move || {
696                                slice
697                                    .iter()
698                                    .map(|d| Self::project_doc(d, matcher, canonical, categories, motifs))
699                                    .collect::<Vec<_>>()
700                            })
701                        })
702                        .collect();
703                    // joined in spawn order, which is document order, so the concatenation stays ordered
704                    for h in handles {
705                        chunks.push(h.join().expect("projection worker panicked"));
706                    }
707                });
708                return chunks.into_iter().flatten().collect();
709            }
710        }
711        docs.iter().map(|d| Self::project_doc(d, matcher, canonical, categories, motifs)).collect()
712    }
713
714    /// Project one document into the tags, display cell, numbers and beliefs of a single situation. Pure and
715    /// self-contained, which is what lets it run on any worker without shared mutable state.
716    fn project_doc(
717        doc: &str,
718        matcher: &crate::emergent::MentionMatcher,
719        canonical: &std::collections::HashMap<&str, &str>,
720        categories: &[(String, Vec<String>)],
721        motifs: &[(String, Vec<String>)],
722    ) -> Projected {
723        let mut tags: Vec<String> = Vec::new();
724        let mut numbers: Vec<(String, f64)> = Vec::new();
725        // Both cue sets, because the two projections had drifted apart here too: the browser copy knew
726        // "provisional" and "no longer" and this one did not. Keeping `belief_level` keeps the fourth
727        // polarity level — a claim that is both denied and hedged is neither a flat denial nor a hedge.
728        let lower = doc.to_lowercase();
729        let level = crate::dimensions::belief_level(
730            lower.contains("not permitted") || lower.contains("is not ") || lower.contains("no longer"),
731            lower.contains("under review") || lower.contains("may be") || lower.contains("provisional"),
732        );
733
734        for mention in matcher.present(doc) {
735            // use the registered token; slugging the surface here is what lost normalisation in v1
736            match canonical.get(mention.as_str()) {
737                Some(tok) => tags.push((*tok).to_string()),
738                None => tags.push(format!("entity/{}", crate::projector::slug(mention))),
739            }
740        }
741        for r in crate::emergent::relation_spans_with(doc, matcher) {
742            let verb = crate::projector::slug(&r.verb);
743            // The bare polarity tags say a relation of this kind happened in this situation. The
744            // argument-bound ones say WHO was on each side, which is what makes a reversed relation
745            // unmatchable rather than merely unranked: "Morty defeated Wallace" carries
746            // `rel/defeated/+/morty-shade`, and the reverse claim has no tag to hide inside.
747            tags.push(format!("rel/{verb}/+"));
748            tags.push(format!("rel/{verb}/+/{}", crate::projector::slug(&r.actor)));
749            tags.push(format!("rel/{verb}/-"));
750            tags.push(format!("rel/{verb}/-/{}", crate::projector::slug(&r.target)));
751        }
752        for (_, _, tok) in crate::emergent::temporal_spans(doc) {
753            tags.push(tok);
754        }
755        for (st, en, field) in crate::emergent::quantity_spans(doc) {
756            tags.push(format!("quantity/{field}"));
757            let digits: String = doc[st..en]
758                .chars()
759                .enumerate()
760                .take_while(|(i, c)| c.is_ascii_digit() || *c == '.' || (*i == 0 && *c == '-'))
761                .map(|(_, c)| c)
762                .collect();
763            if let Ok(v) = digits.parse::<f64>() {
764                numbers.push((field, v));
765            }
766        }
767        for (cat, terms) in categories {
768            for t in terms {
769                if crate::emergent::contains_term(doc, t) {
770                    tags.push(format!("{cat}/{}", crate::projector::slug(t)));
771                }
772            }
773        }
774        // A situation joins a motif through any member term, so two situations can share a theme without
775        // sharing a word — which is the point of the dimension.
776        for (name, terms) in motifs {
777            if terms.iter().any(|t| crate::emergent::contains_term(doc, t)) {
778                tags.push(format!("motif/{}", crate::projector::slug(name)));
779            }
780        }
781        tags.push(
782            match level {
783                l if l < 0.0 => "state/negated",
784                l if l < 1.0 => "state/hedged",
785                _ => "state/asserted",
786            }
787            .to_string(),
788        );
789
790        let beliefs: Vec<(String, f32)> = tags.iter().map(|t| (t.clone(), level)).collect();
791        let display = vec![doc.chars().take(160).collect::<String>()];
792        numbers.dedup_by(|a, b| a.0 == b.0);
793        Projected { tags, display, numbers, beliefs }
794    }
795
796    /// Run a query, or refuse it.
797    ///
798    /// Every tag is checked against the vocabulary before anything executes, so an unsupported query costs
799    /// nothing and comes back with alternatives.
800    pub fn query(&self, ikl: &str) -> Result<Answer, Refused> {
801        let report = self.corpus.linter().lint(ikl);
802        if let Some(fixed) = &report.repaired {
803            // The linter can repair unbalanced parentheses, and reports the repair. Answering the repaired
804            // expression would mean answering a question the caller did not ask — `(and a b` is not `(and a b)`
805            // until someone decides it is. So a query that needed repair is refused, with the repair offered as
806            // a suggestion.
807            return Err(Refused {
808                query: ikl.to_string(),
809                problems: vec![if fixed.trim().is_empty() {
810                    "unbalanced parentheses: a ')' with no matching '(' leaves nothing to run".into()
811                } else {
812                    format!("unbalanced parentheses; did you mean: {fixed}")
813                }],
814                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
815            });
816        }
817        if !report.ok {
818            return Err(Refused {
819                query: ikl.to_string(),
820                problems: report.errors.iter().map(|e| e.message.clone()).collect(),
821                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
822            });
823        }
824        match crate::tokenql::try_evaluate(self.corpus.index(), ikl) {
825            Ok(set) => Ok(Answer { ids: set.to_sorted(), micros: 0.0 }),
826            Err(e) => Err(Refused {
827                query: ikl.to_string(),
828                problems: vec![e.to_string()],
829                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
830            }),
831        }
832    }
833
834    /// Check a query without running it. Cheap, and the same check `query` performs.
835    pub fn check(&self, ikl: &str) -> Result<(), Refused> {
836        let report = self.corpus.linter().lint(ikl);
837        if let Some(fixed) = &report.repaired {
838            // The linter can repair unbalanced parentheses, and reports the repair. Answering the repaired
839            // expression would mean answering a question the caller did not ask — `(and a b` is not `(and a b)`
840            // until someone decides it is. So a query that needed repair is refused, with the repair offered as
841            // a suggestion.
842            return Err(Refused {
843                query: ikl.to_string(),
844                problems: vec![if fixed.trim().is_empty() {
845                    "unbalanced parentheses: a ')' with no matching '(' leaves nothing to run".into()
846                } else {
847                    format!("unbalanced parentheses; did you mean: {fixed}")
848                }],
849                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
850            });
851        }
852        if report.ok {
853            Ok(())
854        } else {
855            Err(Refused {
856                query: ikl.to_string(),
857                problems: report.errors.iter().map(|e| e.message.clone()).collect(),
858                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
859            })
860        }
861    }
862
863    /// The evidential bound on a tag across the whole corpus.
864    pub fn belief(&self, tag: &str) -> Interval {
865        let (belief, plausibility) = self.corpus.belief_interval(tag);
866        Interval { belief, plausibility }
867    }
868
869    /// Situations on a chain from `from` to `to` where each step shares at least `s` tags.
870    ///
871    /// Raising `s` demands more agreement per step, which is what stops a walk drifting somewhere unrelated.
872    pub fn s_path(&self, from: &str, to: &str, s: usize) -> Answer {
873        let set = self.corpus.index().s_path_tokens(from, to, s).map(|chain| {
874            let mut out = P::empty();
875            for tok in &chain {
876                out.or_inplace(&crate::tokenql::TokenStore::atom(self.corpus.index(), tok));
877            }
878            out
879        });
880        Answer { ids: set.map(|s| s.to_sorted()).unwrap_or_default(), micros: 0.0 }
881    }
882
883    /// Both readings of the incidence matrix, swept over the overlap threshold.
884    pub fn filtration(&self, max_s: usize) -> Vec<crate::programs::Level> {
885        crate::programs::s_filtration(self.corpus.index(), max_s, &Default::default(), 128)
886    }
887
888    /// The discovered categories and the words each claims.
889    pub fn categories(&self) -> Vec<Category<'_>> {
890        self.categories.iter().map(|(name, words)| Category { name, words }).collect()
891    }
892
893    /// The wildcard for every discovered category — the set of things you can ask about.
894    pub fn askable(&self) -> Vec<String> {
895        self.categories.iter().map(|(c, _)| format!("{c}/*")).collect()
896    }
897
898    /// Every tag in the index, grouped by its category and sorted within each group.
899    ///
900    /// Sorted because the engine is otherwise deterministic and a caller should not have to defend against
901    /// index iteration order — two runs over the same documents return byte-identical output.
902    pub fn tags(&self) -> BTreeMap<String, Vec<String>> {
903        let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
904        for tag in self.corpus.index().tokens() {
905            let stem = tag.split('/').next().unwrap_or("").to_string();
906            out.entry(stem).or_default().push(tag.clone());
907        }
908        for v in out.values_mut() {
909            v.sort();
910            v.dedup();
911        }
912        out
913    }
914
915    /// The document behind a situation id.
916    ///
917    /// Without this an [`Answer`] is a list of integers. Every result is traceable back to the text that
918    /// produced it, which is what makes an answer checkable rather than merely plausible.
919    pub fn text(&self, situation: u32) -> Option<&str> {
920        self.documents.get(situation as usize).map(|s| s.as_str())
921    }
922
923    /// The documents an answer refers to, in id order.
924    pub fn resolve<'a>(&'a self, answer: &'a Answer) -> impl Iterator<Item = (u32, &'a str)> + 'a {
925        answer.ids().iter().filter_map(move |id| self.text(*id).map(|t| (*id, t)))
926    }
927
928    /// How many situations are indexed.
929    pub fn len(&self) -> usize {
930        self.documents.len()
931    }
932    pub fn is_empty(&self) -> bool {
933        self.documents.is_empty()
934    }
935    /// The documents as given, for tracing a result back to its source.
936    pub fn documents(&self) -> &[String] {
937        &self.documents
938    }
939
940    // ── internals used by `learn`, which needs to re-run the same gate ──
941
942    /// A vocabulary spec matching the currently accepted categories.
943    pub(crate) fn spec_snapshot(&self) -> crate::vocabulary::VocabularySpace {
944        crate::vocabulary::VocabularySpace {
945            version: 1,
946            corpus: "documents".into(),
947            entity_facets: self
948                .categories
949                .iter()
950                .map(|(name, words)| crate::vocabulary::EntityFacet {
951                    name: name.clone(),
952                    parent: None,
953                    description: String::new(),
954                    examples: words.clone(),
955                    structural: false,
956                })
957                .collect(),
958            relation_facets: Vec::new(),
959            gazetteer: Vec::new(),
960            metrics: None,
961        }
962    }
963
964    /// Hand over the index. Used by the WebAssembly layer so the browser demo runs the same projection the
965    /// library does, rather than a second implementation that has to be kept in step by hand.
966    #[cfg(feature = "wasm")]
967    pub(crate) fn into_corpus(self) -> Corpus {
968        self.corpus
969    }
970
971    pub(crate) fn min_gain(&self) -> f64 {
972        self.min_gain
973    }
974
975    pub(crate) fn push_category(&mut self, name: String, words: Vec<String>) {
976        self.categories.push((name, words));
977    }
978
979    /// Rebuild the index. Required after adopting a category, because a new category changes what every
980    /// document projects to — leaving the old index would answer with a vocabulary the spec no longer matches.
981    pub(crate) fn reproject(&mut self) {
982        // reuse the registrations already in hand — adopting a category changes category tags, not the entity
983        // gazetteer, and re-mining would discard a neural gazetteer in favour of a lexical one
984        self.corpus = Self::project_with(&self.documents, &self.categories, &self.registrations, &self.motifs);
985    }
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991
992    fn corpus() -> Vec<String> {
993        [
994            "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational in 2025.",
995            "Bea Strike defeated Iris Draco at Ecruteak City during the Indigo Invitational in 2025.",
996            "A habitat survey recorded Aggron near Sootopolis City at an elevation of 1082 m.",
997            "A habitat survey recorded Salamence near Sootopolis City at an elevation of 2369 m.",
998            "Milotic is not permitted in Series 1 play for the 2025 season.",
999            "Metagross is permitted in Series 4 play for the 2026 season.",
1000        ]
1001        .iter()
1002        .map(|s| s.to_string())
1003        .collect()
1004    }
1005
1006    #[test]
1007    fn three_lines_to_a_working_database() {
1008        let db = SteelDb::ingest(corpus()).expect("index");
1009        assert_eq!(db.len(), 6);
1010        assert!(!db.categories().is_empty(), "should discover at least one category");
1011    }
1012
1013    #[test]
1014    fn an_unsupported_query_is_refused_with_alternatives() {
1015        let db = SteelDb::ingest(corpus()).unwrap();
1016        let err = db.query("gene/brca1").expect_err("must refuse a category the data lacks");
1017        assert!(!err.alternatives.is_empty(), "a refusal must say what does exist");
1018        let shown = err.to_string();
1019        assert!(shown.contains("refused"), "{shown}");
1020        assert!(shown.contains("available"), "{shown}");
1021    }
1022
1023    #[test]
1024    fn a_supported_query_returns_a_complete_set() {
1025        let db = SteelDb::ingest(corpus()).unwrap();
1026        let cat = db.categories()[0].name.to_string();
1027        let answer = db.query(&format!("{cat}/*")).expect("a discovered category must be queryable");
1028        assert!(!answer.is_empty());
1029        // complete, not sampled: every id is a real situation
1030        assert!(answer.ids().iter().all(|id| (*id as usize) < db.len()));
1031        // and iterable directly
1032        assert_eq!(answer.ids().len(), (&answer).into_iter().count());
1033    }
1034
1035    #[test]
1036    fn negation_narrows_rather_than_widens() {
1037        let db = SteelDb::ingest(corpus()).unwrap();
1038        let cat = db.categories()[0].name.to_string();
1039        let all = db.query(&format!("{cat}/*")).unwrap().len();
1040        let some = db.query(&format!("(and {cat}/* (not state/negated))")).unwrap().len();
1041        assert!(some <= all, "excluding something cannot return more: {some} vs {all}");
1042    }
1043
1044    #[test]
1045    fn belief_separates_asserted_from_negated() {
1046        let db = SteelDb::ingest(corpus()).unwrap();
1047        let asserted = db.belief("state/asserted");
1048        let negated = db.belief("state/negated");
1049        assert!(asserted.belief > negated.belief, "{asserted} vs {negated}");
1050        // and the interval exposes its own meaning rather than making the caller compare floats
1051        assert!(asserted.ignorance() >= 0.0);
1052        assert!(db.belief("state/nonexistent").is_unknown(), "an absent tag is unknown, not refuted");
1053    }
1054
1055    #[test]
1056    fn check_costs_nothing_and_agrees_with_query() {
1057        let db = SteelDb::ingest(corpus()).unwrap();
1058        assert!(db.check("gene/brca1").is_err());
1059        assert!(db.query("gene/brca1").is_err());
1060        let cat = db.categories()[0].name.to_string();
1061        assert!(db.check(&format!("{cat}/*")).is_ok());
1062    }
1063
1064    #[test]
1065    fn the_filtration_thins_as_the_threshold_rises() {
1066        let db = SteelDb::ingest(corpus()).unwrap();
1067        let levels = db.filtration(4);
1068        assert_eq!(levels.len(), 4);
1069        // more required agreement can only remove edges
1070        for w in levels.windows(2) {
1071            assert!(w[1].primal.edges <= w[0].primal.edges, "edges must not grow with s");
1072            assert!(w[1].dual.edges <= w[0].dual.edges);
1073        }
1074    }
1075
1076    #[test]
1077    fn empty_input_is_an_error_not_an_empty_database() {
1078        assert!(matches!(SteelDb::ingest(Vec::<String>::new()), Err(Error::Empty(_))));
1079        assert!(matches!(SteelDb::ingest(vec!["   ", ""]), Err(Error::Empty(_))));
1080    }
1081
1082    #[test]
1083    fn artefacts_make_a_later_ingest_reproducible() {
1084        // The pairing that justifies `learn`: the vocabulary is recorded once, and a later run reproduces it
1085        // exactly without a model.
1086        let dir = std::env::temp_dir().join(format!("hsdb_api_repro_{}", std::process::id()));
1087        let _ = std::fs::remove_dir_all(&dir);
1088
1089        let first = SteelDb::ingest(corpus()).unwrap();
1090        first.save(&dir).unwrap();
1091        let cat = first.categories()[0].name.to_string();
1092        let expected = first.query(&format!("{cat}/*")).unwrap().len();
1093
1094        let second = SteelDb::ingest_using(corpus(), &dir).unwrap();
1095        assert_eq!(
1096            second.categories().iter().map(|c| c.name.to_string()).collect::<Vec<_>>(),
1097            first.categories().iter().map(|c| c.name.to_string()).collect::<Vec<_>>(),
1098            "the recorded vocabulary must be reproduced exactly"
1099        );
1100        assert_eq!(second.query(&format!("{cat}/*")).unwrap().len(), expected, "and answer identically");
1101        let _ = std::fs::remove_dir_all(&dir);
1102    }
1103
1104    #[test]
1105    fn saved_artefacts_contain_no_document_text() {
1106        // Leakage guard at the API level: what `save` writes must be vocabulary, never the corpus.
1107        let dir = std::env::temp_dir().join(format!("hsdb_api_leak_{}", std::process::id()));
1108        let _ = std::fs::remove_dir_all(&dir);
1109        let db = SteelDb::ingest(corpus()).unwrap();
1110        db.save(&dir).unwrap();
1111
1112        for entry in std::fs::read_dir(&dir).unwrap() {
1113            let p = entry.unwrap().path();
1114            let text = std::fs::read_to_string(&p).unwrap();
1115            for doc in corpus() {
1116                assert!(
1117                    !text.contains(doc.as_str()),
1118                    "{} contains a whole document",
1119                    p.display()
1120                );
1121                // a distinctive multi-word fragment is enough to prove a copy
1122                let frag: String = doc.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
1123                assert!(!text.contains(&frag), "{} contains the fragment {frag:?}", p.display());
1124            }
1125        }
1126        let _ = std::fs::remove_dir_all(&dir);
1127    }
1128
1129    #[test]
1130    fn ingesting_against_a_missing_artefact_set_is_an_error() {
1131        let missing = std::env::temp_dir().join("hsdb_definitely_absent_dir");
1132        let _ = std::fs::remove_dir_all(&missing);
1133        assert!(matches!(
1134            SteelDb::ingest_using(corpus(), &missing),
1135            Err(Error::Artifact(_))
1136        ));
1137    }
1138
1139    #[test]
1140    fn a_finetuning_set_is_written_separately_from_the_vocabulary() {
1141        let dir = std::env::temp_dir().join(format!("hsdb_api_train_{}", std::process::id()));
1142        let _ = std::fs::remove_dir_all(&dir);
1143        let db = SteelDb::ingest(corpus()).unwrap();
1144        let n = db.save_with_training(&dir).unwrap();
1145        assert!(n > 0, "the corpus should yield labelled passages");
1146
1147        // the committable parts still contain no document text, even though a training set exists
1148        for f in ["manifest.json", "vocabulary.json", "gazetteer.json", "relations.json"] {
1149            let text = std::fs::read_to_string(dir.join(f)).unwrap();
1150            for doc in corpus() {
1151                let frag: String = doc.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
1152                assert!(!text.contains(&frag), "{f} leaked: {frag:?}");
1153            }
1154        }
1155        // and the training file does carry text, which is the point of keeping it apart
1156        let train = std::fs::read_to_string(dir.join("training").join("spans.jsonl")).unwrap();
1157        assert!(train.contains("Morty Shade"), "the finetuning set needs the words");
1158        assert!(dir.join(".gitignore").exists(), "and must be excluded from commits");
1159        let _ = std::fs::remove_dir_all(&dir);
1160    }
1161
1162    #[test]
1163    fn training_spans_do_not_overlap() {
1164        // Overlapping labels would teach a tagger contradictory boundaries for the same characters.
1165        let dir = std::env::temp_dir().join(format!("hsdb_api_ovl_{}", std::process::id()));
1166        let _ = std::fs::remove_dir_all(&dir);
1167        SteelDb::ingest(corpus()).unwrap().save_with_training(&dir).unwrap();
1168        let train = std::fs::read_to_string(dir.join("training").join("spans.jsonl")).unwrap();
1169        for line in train.lines().filter(|l| !l.trim().is_empty()) {
1170            let v: serde_json::Value = serde_json::from_str(line).unwrap();
1171            let spans = v["spans"].as_array().unwrap();
1172            let mut last_end = 0u64;
1173            for sp in spans {
1174                let s = sp["start"].as_u64().unwrap();
1175                let e = sp["end"].as_u64().unwrap();
1176                assert!(s >= last_end, "span {s}..{e} overlaps the previous one ending at {last_end}");
1177                assert!(e > s, "empty span");
1178                last_end = e;
1179            }
1180        }
1181        let _ = std::fs::remove_dir_all(&dir);
1182    }
1183
1184    #[test]
1185    fn registered_variants_still_merge_after_an_artefact_reload() {
1186        // The v1 bug: the artefact recorded only surfaces, so a reload re-derived the token by slugging the raw
1187        // text and two variants of one entity stopped sharing a token. Registration NORMALISES, and that has to
1188        // survive the round trip or ingest is not really following the artefacts.
1189        let dir = std::env::temp_dir().join(format!("hsdb_api_canon_{}", std::process::id()));
1190        let _ = std::fs::remove_dir_all(&dir);
1191
1192        let db = SteelDb::ingest(corpus()).unwrap();
1193        db.save(&dir).unwrap();
1194
1195        let set = crate::artifact::Artifacts::load(&dir).unwrap();
1196        assert!(!set.gazetteer.is_empty(), "the corpus should register some mentions");
1197        for r in &set.gazetteer {
1198            assert!(!r.token.is_empty(), "every registration needs a canonical token");
1199            assert!(r.token.contains('/'), "a token is facet-qualified: {}", r.token);
1200        }
1201
1202        // reloading must reproduce the same entity tags, not re-derive different ones
1203        let reloaded = SteelDb::ingest_using(corpus(), &dir).unwrap();
1204        let tags_before: Vec<String> =
1205            db.tags().get("entity").cloned().unwrap_or_default();
1206        let tags_after: Vec<String> =
1207            reloaded.tags().get("entity").cloned().unwrap_or_default();
1208        assert_eq!(tags_before, tags_after, "entity tags must survive the round trip unchanged");
1209        let _ = std::fs::remove_dir_all(&dir);
1210    }
1211
1212    #[test]
1213    fn parallel_projection_preserves_document_order() {
1214        // Projection runs across several worker threads, so a chunk added out of order would misalign every
1215        // situation id with its document. Enough documents to force more than one chunk, then check that
1216        // situation i still shows document i.
1217        let docs: Vec<String> = (0..40)
1218            .map(|i| format!("Trainer{i} Shade defeated Rival{i} Gale at Ecruteak City in 2025."))
1219            .collect();
1220        let db = SteelDb::ingest(docs.clone()).expect("ingest");
1221        assert_eq!(db.len(), 40);
1222        for (i, doc) in docs.iter().enumerate() {
1223            let shown = db.text(i as u32).expect("every situation resolves");
1224            let head: String = doc.chars().take(20).collect();
1225            assert!(shown.starts_with(&head), "situation {i} shows {shown:?}, not document {i} ({head:?})");
1226        }
1227    }
1228
1229    #[test]
1230    fn ingest_is_deterministic_under_parallelism() {
1231        // Two runs over the same documents must be byte-identical, or the parallel split has leaked into the
1232        // result. 40 documents guarantees multiple worker chunks.
1233        let docs: Vec<String> = (0..40)
1234            .map(|i| {
1235                if i % 2 == 0 {
1236                    format!("Trainer{i} defeated Rival{i} at Ecruteak City in 2025.")
1237                } else {
1238                    format!("A survey recorded Aggron near Sootopolis City at {} m.", 600 + i)
1239                }
1240            })
1241            .collect();
1242        let a = SteelDb::ingest(docs.clone()).expect("a");
1243        let b = SteelDb::ingest(docs).expect("b");
1244        assert_eq!(a.tags(), b.tags(), "parallel ingest is not deterministic");
1245        assert_eq!(a.askable(), b.askable());
1246    }
1247
1248    #[test]
1249    fn an_artefact_reload_indexes_identically_to_discovery() {
1250        // The promise `ingest_using` makes is that the expensive step happens once and every run afterwards is
1251        // the same. That only holds if the artefact captures everything the projection needs — motifs were
1252        // missing in schema 2, so a reload silently produced no motif/* tokens and the same documents indexed
1253        // two different ways depending on which path they came through.
1254        let docs = corpus();
1255        let db = SteelDb::ingest(docs.clone()).expect("ingest");
1256
1257        let dir = std::env::temp_dir().join(format!("steeldb-reload-{}", std::process::id()));
1258        let _ = std::fs::remove_dir_all(&dir);
1259        db.save(&dir).expect("save");
1260        let reloaded = SteelDb::ingest_using(docs, &dir).expect("reload");
1261
1262        assert_eq!(db.tags(), reloaded.tags(), "an artefact reload must index identically");
1263        assert_eq!(db.askable(), reloaded.askable());
1264        let _ = std::fs::remove_dir_all(&dir);
1265    }
1266
1267    #[test]
1268    fn a_relation_records_who_was_on_each_side() {
1269        // Bare polarity says a defeat happened; it does not say who won. Without the argument-bound tokens the
1270        // reverse claim matches exactly the same set, which is the one thing a directional relation must not do.
1271        let db = SteelDb::ingest(corpus()).expect("ingest");
1272        let rel = db.tags().get("rel").cloned().unwrap_or_default();
1273
1274        let bound: Vec<&String> = rel.iter().filter(|t| t.matches('/').count() == 3).collect();
1275        assert!(!bound.is_empty(), "no argument-bound relation tokens: {rel:?}");
1276        assert!(
1277            bound.iter().any(|t| t.contains("/+/") ) && bound.iter().any(|t| t.contains("/-/")),
1278            "both sides must be recorded: {bound:?}"
1279        );
1280        // and the mention must survive whole — `shade` instead of `morty-shade` loses the person
1281        assert!(
1282            rel.iter().any(|t| t.ends_with("morty-shade")),
1283            "a name opening a sentence must not be truncated: {rel:?}"
1284        );
1285    }
1286
1287    #[test]
1288    fn a_refusal_stays_readable_rather_than_running_off_the_line() {
1289        // This is the message a user sees at the moment they are confused, so width matters. It listed every
1290        // dimension in the corpus on one line, which came to 122 characters and wrapped wherever the terminal
1291        // happened to end.
1292        let db = SteelDb::ingest(corpus()).unwrap();
1293        let shown = db.query("gene/brca1").unwrap_err().to_string();
1294
1295        for line in shown.lines() {
1296            assert!(line.chars().count() <= 78, "line is {} chars: {line:?}", line.chars().count());
1297        }
1298        // wrapping must not drop or mangle an item
1299        assert!(shown.contains("defeated"), "{shown}");
1300        assert!(!shown.contains(",,") && !shown.contains(" ,"), "mangled list: {shown}");
1301        // a broken line keeps its comma, so the list does not read as if an entry were missing
1302        for line in shown.lines() {
1303            let t = line.trim_end();
1304            if t.ends_with("defeated") || t.ends_with("elevation") {
1305                panic!("a wrapped list line lost its comma: {shown}");
1306            }
1307        }
1308    }
1309
1310    #[test]
1311    fn wrapping_leaves_a_short_message_untouched() {
1312        assert_eq!(wrap_indented("a, b", 70, "  "), "a, b");
1313        assert_eq!(wrap_indented("", 70, "  "), "");
1314        assert_eq!(wrap_indented("single", 2, "  "), "single", "one oversized item cannot be split");
1315    }
1316
1317    #[test]
1318    fn unbalanced_parentheses_are_refused_rather_than_repaired_or_crashed() {
1319        // Two faults found by stressing the published crate. `query(")")` PANICKED: the linter repairs
1320        // parentheses and reported the query clean, then evaluation parsed the raw string and hit an
1321        // `expect("unbalanced )")`. In the wasm build a panic takes the whole module down.
1322        //
1323        // The second fault was quieter and worse. Because the linter repairs, `(and a b` was reported clean and
1324        // then evaluated as `(and a b)` — answering a question the caller had not asked. A repair is a
1325        // suggestion, not a licence to rewrite.
1326        let db = SteelDb::ingest(corpus()).unwrap();
1327
1328        for q in [")", "(", "(and", "))))", "((((", "(and a b", "(or (not x"] {
1329            let e = db
1330                .query(q)
1331                .err()
1332                .unwrap_or_else(|| panic!("{q:?} was answered instead of refused"));
1333            assert!(
1334                e.problems.iter().any(|p| p.contains("unbalanced")),
1335                "{q:?} refused for the wrong reason: {:?}",
1336                e.problems
1337            );
1338            // check() must agree, or one path answers what the other rejects
1339            assert!(db.check(q).is_err(), "check accepted {q:?} while query refused it");
1340        }
1341
1342        // a balanced expression is unaffected
1343        assert!(db.query("state/asserted").is_ok());
1344        assert!(db.query("(not state/negated)").is_ok());
1345    }
1346
1347    #[test]
1348    fn the_parser_never_panics_on_hostile_input() {
1349        // `tokenql::parse` is public, so it is reachable with any string at all.
1350        for q in [")", "((", "()", "\"", "\"unclosed", "(\")\")", "\0", "(((((((((((((((((((("] {
1351            let _ = crate::tokenql::parse(q);
1352        }
1353    }
1354}