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    Io(std::io::Error),
194}
195
196impl std::fmt::Display for Error {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        match self {
199            Error::Empty(what) => write!(f, "nothing to index: {what}"),
200            Error::Artifact(e) => write!(f, "{e}"),
201            Error::Io(e) => write!(f, "{e}"),
202        }
203    }
204}
205
206impl std::error::Error for Error {}
207
208impl From<std::io::Error> for Error {
209    fn from(e: std::io::Error) -> Self {
210        Error::Io(e)
211    }
212}
213
214// ── the handle ────────────────────────────────────────────────────────────────────────────────────────
215
216/// How to build the vocabulary. The defaults are tuned for prose and rarely need changing.
217#[derive(Debug, Clone)]
218pub struct Options {
219    /// how much candidate vocabulary to consider
220    pub terms: usize,
221    /// how many categories to look for
222    pub categories: usize,
223    /// a candidate must clear `coverage × (1 − overlap)` to be kept
224    pub min_gain: f64,
225}
226
227impl Default for Options {
228    fn default() -> Self {
229        Options { terms: 90, categories: 6, min_gain: 0.05 }
230    }
231}
232
233/// An indexed corpus you can ask questions of.
234///
235/// Read-only after construction and `Send + Sync`, so one instance can serve many threads.
236pub struct SteelDb {
237    corpus: Corpus,
238    categories: Vec<(String, Vec<String>)>,
239    /// latent motifs: the themes `motif/*` tokens come from (the paper's sixth dimension)
240    motifs: Vec<(String, Vec<String>)>,
241    documents: Vec<String>,
242    /// retained so `learn` gates adopted candidates on the same threshold ingest used
243    min_gain: f64,
244}
245
246/// Terms considered when looking for latent motifs. Fewer than for categories on purpose: a motif is a broad
247/// theme, and the long tail of rare terms adds noise rather than themes.
248const MOTIF_TERMS: usize = 40;
249/// How many motifs to look for. The transport solver clamps this down on a small corpus.
250const MOTIF_GROUPS: usize = 3;
251
252impl SteelDb {
253    /// **Ingest.** Discover a vocabulary from documents and index them.
254    ///
255    /// Offline and deterministic: no credentials, no network, no model files. The same documents always give
256    /// the same vocabulary.
257    pub fn ingest<I, S>(docs: I) -> Result<Self, Error>
258    where
259        I: IntoIterator<Item = S>,
260        S: AsRef<str>,
261    {
262        Self::ingest_with(docs, Options::default())
263    }
264
265    /// As [`SteelDb::ingest`], with explicit discovery settings.
266    pub fn ingest_with<I, S>(docs: I, opts: Options) -> Result<Self, Error>
267    where
268        I: IntoIterator<Item = S>,
269        S: AsRef<str>,
270    {
271        let documents: Vec<String> =
272            docs.into_iter().map(|d| d.as_ref().trim().to_string()).filter(|d| !d.is_empty()).collect();
273        if documents.is_empty() {
274            return Err(Error::Empty("no non-empty documents".into()));
275        }
276
277        // discover categories, keeping only those that earn their place
278        let clusters = crate::emergent::discover(&documents, opts.terms, opts.categories);
279        let mut spec = crate::vocabulary::VocabularySpace {
280            version: 1,
281            corpus: "documents".into(),
282            entity_facets: Vec::new(),
283            relation_facets: Vec::new(),
284            gazetteer: Vec::new(),
285            metrics: None,
286        };
287        let mut categories: Vec<(String, Vec<String>)> = Vec::new();
288        for (round, c) in clusters.iter().enumerate() {
289            let cand = crate::grow::Candidate {
290                name: c.label.clone(),
291                parent: None,
292                description: String::new(),
293                examples: c.terms.clone(),
294                worth_adding: true,
295            };
296            let scored = crate::grow::score_candidate_full(&spec, &documents, &cand);
297            let (score, dup) = match scored {
298                Some((s, d)) => (Some(s), d),
299                None => (None, None),
300            };
301            if crate::grow::gate_full(&spec, &cand, score.as_ref(), dup, opts.min_gain, round).kept {
302                crate::grow::adopt(&mut spec, &cand);
303                categories.push((c.label.clone(), c.terms.clone()));
304            }
305        }
306
307        // Latent motifs are not gated the way categories are: they are not competing for a retrieval head, they
308        // are themes a situation can join through terms that travel together, and nothing is refused on their
309        // account. They do have to be LATENT, though. Run over the same geometry, transport often recovers the
310        // groups the categories already name, and on a small corpus every motif came back as an exact restatement
311        // of a category — `motif/defeated` beside `defeated/*`, carrying the same situations under a second name.
312        // A motif that a category already expresses is dropped, so `motif/*` means "structure the categories did
313        // not capture" and an empty motif set is the honest answer rather than a padded one.
314        let motifs: Vec<(String, Vec<String>)> =
315            crate::emergent::discover_motifs(&documents, MOTIF_TERMS, MOTIF_GROUPS)
316                .into_iter()
317                .filter(|(name, _)| !categories.iter().any(|(cat, _)| cat == name))
318                .collect();
319
320        let corpus = Self::project(&documents, &categories, &motifs);
321        Ok(SteelDb { corpus, categories, motifs, documents, min_gain: opts.min_gain })
322    }
323
324    /// **Ingest, following an existing artefact set.**
325    ///
326    /// Reads the vocabulary from `artifact_dir` instead of rediscovering it, so the result is reproducible and
327    /// no model is involved even if a model produced the vocabulary originally. This is the pairing that makes
328    /// `learn` worth running: the expensive, non-deterministic step happens once, and every run afterwards is
329    /// offline and identical.
330    pub fn ingest_using<I, S>(docs: I, artifact_dir: impl AsRef<Path>) -> Result<Self, Error>
331    where
332        I: IntoIterator<Item = S>,
333        S: AsRef<str>,
334    {
335        let set = crate::artifact::Artifacts::load(artifact_dir).map_err(Error::Artifact)?;
336        let documents: Vec<String> =
337            docs.into_iter().map(|d| d.as_ref().trim().to_string()).filter(|d| !d.is_empty()).collect();
338        if documents.is_empty() {
339            return Err(Error::Empty("no non-empty documents".into()));
340        }
341        let categories: Vec<(String, Vec<String>)> =
342            set.categories.into_iter().map(|c| (c.name, c.words)).collect();
343        // motifs come from the artefact rather than being rediscovered, so a reload cannot drift from the run
344        // that produced the files
345        let motifs: Vec<(String, Vec<String>)> =
346            set.motifs.into_iter().map(|m| (m.name, m.words)).collect();
347        let corpus = Self::project_with(&documents, &categories, &set.gazetteer, &motifs);
348        Ok(SteelDb { corpus, categories, motifs, documents, min_gain: Options::default().min_gain })
349    }
350
351    /// Write this database's vocabulary to an artefact directory.
352    ///
353    /// Only derived vocabulary is written — categories, mention surfaces, relation verbs. No document text, so
354    /// the directory is safe to commit alongside code.
355    pub fn save(&self, artifact_dir: impl AsRef<Path>) -> Result<(), Error> {
356        let categories = self
357            .categories
358            .iter()
359            .map(|(name, words)| crate::artifact::CategoryRecord {
360                name: name.clone(),
361                words: words.clone(),
362            })
363            .collect();
364        let registrations = Self::registrations(&self.documents);
365        let surfaces: Vec<String> = registrations.iter().map(|r| r.surface.clone()).collect();
366        let mut relations: Vec<String> = Vec::new();
367        for doc in &self.documents {
368            for r in crate::emergent::relation_spans(doc, &surfaces) {
369                if !relations.contains(&r.verb) {
370                    relations.push(r.verb);
371                }
372            }
373        }
374        let motifs = self
375            .motifs
376            .iter()
377            .map(|(name, words)| crate::artifact::CategoryRecord {
378                name: name.clone(),
379                words: words.clone(),
380            })
381            .collect();
382        crate::artifact::Artifacts::new("discovery", categories, registrations, relations)
383            .with_motifs(motifs)
384            .save(artifact_dir)
385            .map_err(Error::Artifact)
386    }
387
388    /// As [`SteelDb::save`], and additionally write a **finetuning set** derived from the corpus.
389    ///
390    /// The set is weak supervision: every span the discovered vocabulary can locate, labelled with the category
391    /// that claims it. It is what you would hand to a tagger finetune so the model learns to find these spans in
392    /// text it has not seen.
393    ///
394    /// Unlike the vocabulary files, this one **contains document text** — a span label is meaningless without
395    /// the words it points at. It is written to a `training/` subdirectory which
396    /// [`crate::artifact::Artifacts::save`] excludes with a `.gitignore`, and the manifest records that the
397    /// subdirectory is unsafe to publish.
398    pub fn save_with_training(&self, artifact_dir: impl AsRef<Path>) -> Result<usize, Error> {
399        let gazetteer = crate::emergent::mine_gazetteer(&self.documents, 2);
400        let mut jsonl = String::new();
401        for doc in &self.documents {
402            let mut spans: Vec<serde_json::Value> = Vec::new();
403            let mut push = |s: usize, e: usize, facet: &str| {
404                if let Some(surface) = doc.get(s..e) {
405                    spans.push(serde_json::json!({
406                        "start": s, "end": e, "facet": facet, "surface": surface,
407                    }));
408                }
409            };
410            for m in &gazetteer {
411                for (s, e) in crate::emergent::word_spans(doc, m) {
412                    push(s, e, "entity");
413                }
414            }
415            for (s, e, field) in crate::emergent::quantity_spans(doc) {
416                push(s, e, &format!("qty/{field}"));
417            }
418            for (s, e, tok) in crate::emergent::temporal_spans(doc) {
419                let _ = tok;
420                push(s, e, "time");
421            }
422            for (cat, words) in &self.categories {
423                for w in words {
424                    for (s, e) in crate::emergent::word_spans(doc, w) {
425                        push(s, e, cat);
426                    }
427                }
428            }
429            // overlapping labels would teach the tagger contradictory boundaries
430            spans.sort_by_key(|v| (v["start"].as_u64().unwrap_or(0), v["end"].as_u64().unwrap_or(0)));
431            let mut kept: Vec<serde_json::Value> = Vec::new();
432            let mut cursor = 0u64;
433            for sp in spans {
434                let (s, e) = (sp["start"].as_u64().unwrap_or(0), sp["end"].as_u64().unwrap_or(0));
435                if s >= cursor {
436                    cursor = e;
437                    kept.push(sp);
438                }
439            }
440            if kept.is_empty() {
441                continue; // a passage with no labels teaches nothing
442            }
443            let line = serde_json::json!({ "text": doc, "spans": kept });
444            jsonl.push_str(&line.to_string());
445            jsonl.push('\n');
446        }
447
448        let categories = self
449            .categories
450            .iter()
451            .map(|(name, words)| crate::artifact::CategoryRecord {
452                name: name.clone(),
453                words: words.clone(),
454            })
455            .collect();
456        let mut relations: Vec<String> = Vec::new();
457        for doc in &self.documents {
458            for r in crate::emergent::relation_spans(doc, &gazetteer) {
459                if !relations.contains(&r.verb) {
460                    relations.push(r.verb);
461                }
462            }
463        }
464        let set = crate::artifact::Artifacts::new(
465            "discovery",
466            categories,
467            gazetteer.iter().map(|s| crate::artifact::Registration {
468                surface: s.clone(),
469                token: format!("entity/{}", crate::projector::slug(s)),
470            }).collect(),
471            relations,
472        )
473        .with_motifs(
474            self.motifs
475                .iter()
476                .map(|(name, words)| crate::artifact::CategoryRecord {
477                    name: name.clone(),
478                    words: words.clone(),
479                })
480                .collect(),
481        )
482        .with_training(jsonl);
483        let n = set.training_examples;
484        set.save(artifact_dir).map_err(Error::Artifact)?;
485        Ok(n)
486    }
487
488    /// Index a directory of documents, discovering the vocabulary from what it finds.
489    pub fn open(dir: impl AsRef<Path>) -> Result<Self, Error> {
490        let dir = dir.as_ref();
491        let mut docs: Vec<String> = Vec::new();
492        for entry in std::fs::read_dir(dir)? {
493            let path = entry?.path();
494            if path.extension().map(|e| e == "md" || e == "txt").unwrap_or(false) {
495                if let Ok(text) = std::fs::read_to_string(&path) {
496                    docs.push(text);
497                }
498            }
499        }
500        if docs.is_empty() {
501            return Err(Error::Empty(format!("no .md or .txt files under {}", dir.display())));
502        }
503        Self::ingest(docs)
504    }
505
506    /// Project documents into tagged situations. One situation per document, carrying every dimension it
507    /// supports: entities, relation roles with polarity, temporal buckets, quantities, epistemic state.
508    fn project(
509        docs: &[String],
510        categories: &[(String, Vec<String>)],
511        motifs: &[(String, Vec<String>)],
512    ) -> Corpus {
513        Self::project_with(docs, categories, &Self::registrations(docs), motifs)
514    }
515
516    /// Mine mentions and register each under its canonical token, so discovery and an artefact reload agree.
517    fn registrations(docs: &[String]) -> Vec<crate::artifact::Registration> {
518        crate::emergent::mine_gazetteer(docs, 2)
519            .into_iter()
520            .map(|surface| {
521                let token = format!("entity/{}", crate::projector::slug(&surface));
522                crate::artifact::Registration { surface, token }
523            })
524            .collect()
525    }
526
527    /// As [`Self::project`], with the mention list supplied rather than mined — so an artefact set fully
528    /// determines the projection and a later run cannot drift from the recorded vocabulary.
529    fn project_with(
530        docs: &[String],
531        categories: &[(String, Vec<String>)],
532        registrations: &[crate::artifact::Registration],
533        motifs: &[(String, Vec<String>)],
534    ) -> Corpus {
535        let gazetteer: Vec<String> = registrations.iter().map(|r| r.surface.clone()).collect();
536        // surface -> canonical token, so a normalised registration is honoured rather than re-derived
537        let canonical: std::collections::HashMap<&str, &str> =
538            registrations.iter().map(|r| (r.surface.as_str(), r.token.as_str())).collect();
539        let mut corpus = Corpus::new_incremental("documents", vec!["document".into()], CorpusKind::Text);
540
541        for doc in docs {
542            let mut tags: Vec<String> = Vec::new();
543            let mut numbers: Vec<(String, f64)> = Vec::new();
544            // Both cue sets, because the two projections had drifted apart here too: the browser copy knew
545            // "provisional" and "no longer" and this one did not. Keeping `belief_level` keeps the fourth
546            // polarity level — a claim that is both denied and hedged is neither a flat denial nor a hedge.
547            let lower = doc.to_lowercase();
548            let level = crate::dimensions::belief_level(
549                lower.contains("not permitted") || lower.contains("is not ") || lower.contains("no longer"),
550                lower.contains("under review") || lower.contains("may be") || lower.contains("provisional"),
551            );
552
553            for mention in &gazetteer {
554                if crate::emergent::contains_term(doc, mention) {
555                    // use the registered token; slugging the surface here is what lost normalisation in v1
556                    match canonical.get(mention.as_str()) {
557                        Some(tok) => tags.push((*tok).to_string()),
558                        None => tags.push(format!("entity/{}", crate::projector::slug(mention))),
559                    }
560                }
561            }
562            for r in crate::emergent::relation_spans(doc, &gazetteer) {
563                let verb = crate::projector::slug(&r.verb);
564                // The bare polarity tags say a relation of this kind happened in this situation. The
565                // argument-bound ones say WHO was on each side, which is what makes a reversed relation
566                // unmatchable rather than merely unranked: "Morty defeated Wallace" carries
567                // `rel/defeated/+/morty-shade`, and the reverse claim has no tag to hide inside.
568                tags.push(format!("rel/{verb}/+"));
569                tags.push(format!("rel/{verb}/+/{}", crate::projector::slug(&r.actor)));
570                tags.push(format!("rel/{verb}/-"));
571                tags.push(format!("rel/{verb}/-/{}", crate::projector::slug(&r.target)));
572            }
573            for (_, _, tok) in crate::emergent::temporal_spans(doc) {
574                tags.push(tok);
575            }
576            for (st, en, field) in crate::emergent::quantity_spans(doc) {
577                tags.push(format!("quantity/{field}"));
578                let digits: String = doc[st..en]
579                    .chars()
580                    .enumerate()
581                    .take_while(|(i, c)| c.is_ascii_digit() || *c == '.' || (*i == 0 && *c == '-'))
582                    .map(|(_, c)| c)
583                    .collect();
584                if let Ok(v) = digits.parse::<f64>() {
585                    numbers.push((field, v));
586                }
587            }
588            for (cat, terms) in categories {
589                for t in terms {
590                    if crate::emergent::contains_term(doc, t) {
591                        tags.push(format!("{cat}/{}", crate::projector::slug(t)));
592                    }
593                }
594            }
595            // A situation joins a motif through any member term, so two situations can share a theme without
596            // sharing a word — which is the point of the dimension.
597            for (name, terms) in motifs {
598                if terms.iter().any(|t| crate::emergent::contains_term(doc, t)) {
599                    tags.push(format!("motif/{}", crate::projector::slug(name)));
600                }
601            }
602            tags.push(
603                match level {
604                    l if l < 0.0 => "state/negated",
605                    l if l < 1.0 => "state/hedged",
606                    _ => "state/asserted",
607                }
608                .to_string(),
609            );
610
611            let beliefs: Vec<(String, f32)> = tags.iter().map(|t| (t.clone(), level)).collect();
612            let display = vec![doc.chars().take(160).collect::<String>()];
613            numbers.dedup_by(|a, b| a.0 == b.0);
614            corpus.add_situation_polar(tags, display, numbers, beliefs);
615        }
616        corpus
617    }
618
619    /// Run a query, or refuse it.
620    ///
621    /// Every tag is checked against the vocabulary before anything executes, so an unsupported query costs
622    /// nothing and comes back with alternatives.
623    pub fn query(&self, ikl: &str) -> Result<Answer, Refused> {
624        let report = self.corpus.linter().lint(ikl);
625        if let Some(fixed) = &report.repaired {
626            // The linter can repair unbalanced parentheses, and reports the repair. Answering the repaired
627            // expression would mean answering a question the caller did not ask — `(and a b` is not `(and a b)`
628            // until someone decides it is. So a query that needed repair is refused, with the repair offered as
629            // a suggestion.
630            return Err(Refused {
631                query: ikl.to_string(),
632                problems: vec![if fixed.trim().is_empty() {
633                    "unbalanced parentheses: a ')' with no matching '(' leaves nothing to run".into()
634                } else {
635                    format!("unbalanced parentheses; did you mean: {fixed}")
636                }],
637                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
638            });
639        }
640        if !report.ok {
641            return Err(Refused {
642                query: ikl.to_string(),
643                problems: report.errors.iter().map(|e| e.message.clone()).collect(),
644                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
645            });
646        }
647        match crate::tokenql::try_evaluate(self.corpus.index(), ikl) {
648            Ok(set) => Ok(Answer { ids: set.to_sorted(), micros: 0.0 }),
649            Err(e) => Err(Refused {
650                query: ikl.to_string(),
651                problems: vec![e.to_string()],
652                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
653            }),
654        }
655    }
656
657    /// Check a query without running it. Cheap, and the same check `query` performs.
658    pub fn check(&self, ikl: &str) -> Result<(), Refused> {
659        let report = self.corpus.linter().lint(ikl);
660        if let Some(fixed) = &report.repaired {
661            // The linter can repair unbalanced parentheses, and reports the repair. Answering the repaired
662            // expression would mean answering a question the caller did not ask — `(and a b` is not `(and a b)`
663            // until someone decides it is. So a query that needed repair is refused, with the repair offered as
664            // a suggestion.
665            return Err(Refused {
666                query: ikl.to_string(),
667                problems: vec![if fixed.trim().is_empty() {
668                    "unbalanced parentheses: a ')' with no matching '(' leaves nothing to run".into()
669                } else {
670                    format!("unbalanced parentheses; did you mean: {fixed}")
671                }],
672                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
673            });
674        }
675        if report.ok {
676            Ok(())
677        } else {
678            Err(Refused {
679                query: ikl.to_string(),
680                problems: report.errors.iter().map(|e| e.message.clone()).collect(),
681                alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
682            })
683        }
684    }
685
686    /// The evidential bound on a tag across the whole corpus.
687    pub fn belief(&self, tag: &str) -> Interval {
688        let (belief, plausibility) = self.corpus.belief_interval(tag);
689        Interval { belief, plausibility }
690    }
691
692    /// Situations on a chain from `from` to `to` where each step shares at least `s` tags.
693    ///
694    /// Raising `s` demands more agreement per step, which is what stops a walk drifting somewhere unrelated.
695    pub fn s_path(&self, from: &str, to: &str, s: usize) -> Answer {
696        let set = self.corpus.index().s_path_tokens(from, to, s).map(|chain| {
697            let mut out = P::empty();
698            for tok in &chain {
699                out.or_inplace(&crate::tokenql::TokenStore::atom(self.corpus.index(), tok));
700            }
701            out
702        });
703        Answer { ids: set.map(|s| s.to_sorted()).unwrap_or_default(), micros: 0.0 }
704    }
705
706    /// Both readings of the incidence matrix, swept over the overlap threshold.
707    pub fn filtration(&self, max_s: usize) -> Vec<crate::programs::Level> {
708        crate::programs::s_filtration(self.corpus.index(), max_s, &Default::default(), 128)
709    }
710
711    /// The discovered categories and the words each claims.
712    pub fn categories(&self) -> Vec<Category<'_>> {
713        self.categories.iter().map(|(name, words)| Category { name, words }).collect()
714    }
715
716    /// The wildcard for every discovered category — the set of things you can ask about.
717    pub fn askable(&self) -> Vec<String> {
718        self.categories.iter().map(|(c, _)| format!("{c}/*")).collect()
719    }
720
721    /// Every tag in the index, grouped by its category and sorted within each group.
722    ///
723    /// Sorted because the engine is otherwise deterministic and a caller should not have to defend against
724    /// index iteration order — two runs over the same documents return byte-identical output.
725    pub fn tags(&self) -> BTreeMap<String, Vec<String>> {
726        let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
727        for tag in self.corpus.index().tokens() {
728            let stem = tag.split('/').next().unwrap_or("").to_string();
729            out.entry(stem).or_default().push(tag.clone());
730        }
731        for v in out.values_mut() {
732            v.sort();
733            v.dedup();
734        }
735        out
736    }
737
738    /// The document behind a situation id.
739    ///
740    /// Without this an [`Answer`] is a list of integers. Every result is traceable back to the text that
741    /// produced it, which is what makes an answer checkable rather than merely plausible.
742    pub fn text(&self, situation: u32) -> Option<&str> {
743        self.documents.get(situation as usize).map(|s| s.as_str())
744    }
745
746    /// The documents an answer refers to, in id order.
747    pub fn resolve<'a>(&'a self, answer: &'a Answer) -> impl Iterator<Item = (u32, &'a str)> + 'a {
748        answer.ids().iter().filter_map(move |id| self.text(*id).map(|t| (*id, t)))
749    }
750
751    /// How many situations are indexed.
752    pub fn len(&self) -> usize {
753        self.documents.len()
754    }
755    pub fn is_empty(&self) -> bool {
756        self.documents.is_empty()
757    }
758    /// The documents as given, for tracing a result back to its source.
759    pub fn documents(&self) -> &[String] {
760        &self.documents
761    }
762
763    // ── internals used by `learn`, which needs to re-run the same gate ──
764
765    /// A vocabulary spec matching the currently accepted categories.
766    pub(crate) fn spec_snapshot(&self) -> crate::vocabulary::VocabularySpace {
767        crate::vocabulary::VocabularySpace {
768            version: 1,
769            corpus: "documents".into(),
770            entity_facets: self
771                .categories
772                .iter()
773                .map(|(name, words)| crate::vocabulary::EntityFacet {
774                    name: name.clone(),
775                    parent: None,
776                    description: String::new(),
777                    examples: words.clone(),
778                    structural: false,
779                })
780                .collect(),
781            relation_facets: Vec::new(),
782            gazetteer: Vec::new(),
783            metrics: None,
784        }
785    }
786
787    /// Hand over the index. Used by the WebAssembly layer so the browser demo runs the same projection the
788    /// library does, rather than a second implementation that has to be kept in step by hand.
789    #[cfg(feature = "wasm")]
790    pub(crate) fn into_corpus(self) -> Corpus {
791        self.corpus
792    }
793
794    pub(crate) fn min_gain(&self) -> f64 {
795        self.min_gain
796    }
797
798    pub(crate) fn push_category(&mut self, name: String, words: Vec<String>) {
799        self.categories.push((name, words));
800    }
801
802    /// Rebuild the index. Required after adopting a category, because a new category changes what every
803    /// document projects to — leaving the old index would answer with a vocabulary the spec no longer matches.
804    pub(crate) fn reproject(&mut self) {
805        self.corpus = Self::project(&self.documents, &self.categories, &self.motifs);
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812
813    fn corpus() -> Vec<String> {
814        [
815            "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational in 2025.",
816            "Bea Strike defeated Iris Draco at Ecruteak City during the Indigo Invitational in 2025.",
817            "A habitat survey recorded Aggron near Sootopolis City at an elevation of 1082 m.",
818            "A habitat survey recorded Salamence near Sootopolis City at an elevation of 2369 m.",
819            "Milotic is not permitted in Series 1 play for the 2025 season.",
820            "Metagross is permitted in Series 4 play for the 2026 season.",
821        ]
822        .iter()
823        .map(|s| s.to_string())
824        .collect()
825    }
826
827    #[test]
828    fn three_lines_to_a_working_database() {
829        let db = SteelDb::ingest(corpus()).expect("index");
830        assert_eq!(db.len(), 6);
831        assert!(!db.categories().is_empty(), "should discover at least one category");
832    }
833
834    #[test]
835    fn an_unsupported_query_is_refused_with_alternatives() {
836        let db = SteelDb::ingest(corpus()).unwrap();
837        let err = db.query("gene/brca1").expect_err("must refuse a category the data lacks");
838        assert!(!err.alternatives.is_empty(), "a refusal must say what does exist");
839        let shown = err.to_string();
840        assert!(shown.contains("refused"), "{shown}");
841        assert!(shown.contains("available"), "{shown}");
842    }
843
844    #[test]
845    fn a_supported_query_returns_a_complete_set() {
846        let db = SteelDb::ingest(corpus()).unwrap();
847        let cat = db.categories()[0].name.to_string();
848        let answer = db.query(&format!("{cat}/*")).expect("a discovered category must be queryable");
849        assert!(!answer.is_empty());
850        // complete, not sampled: every id is a real situation
851        assert!(answer.ids().iter().all(|id| (*id as usize) < db.len()));
852        // and iterable directly
853        assert_eq!(answer.ids().len(), (&answer).into_iter().count());
854    }
855
856    #[test]
857    fn negation_narrows_rather_than_widens() {
858        let db = SteelDb::ingest(corpus()).unwrap();
859        let cat = db.categories()[0].name.to_string();
860        let all = db.query(&format!("{cat}/*")).unwrap().len();
861        let some = db.query(&format!("(and {cat}/* (not state/negated))")).unwrap().len();
862        assert!(some <= all, "excluding something cannot return more: {some} vs {all}");
863    }
864
865    #[test]
866    fn belief_separates_asserted_from_negated() {
867        let db = SteelDb::ingest(corpus()).unwrap();
868        let asserted = db.belief("state/asserted");
869        let negated = db.belief("state/negated");
870        assert!(asserted.belief > negated.belief, "{asserted} vs {negated}");
871        // and the interval exposes its own meaning rather than making the caller compare floats
872        assert!(asserted.ignorance() >= 0.0);
873        assert!(db.belief("state/nonexistent").is_unknown(), "an absent tag is unknown, not refuted");
874    }
875
876    #[test]
877    fn check_costs_nothing_and_agrees_with_query() {
878        let db = SteelDb::ingest(corpus()).unwrap();
879        assert!(db.check("gene/brca1").is_err());
880        assert!(db.query("gene/brca1").is_err());
881        let cat = db.categories()[0].name.to_string();
882        assert!(db.check(&format!("{cat}/*")).is_ok());
883    }
884
885    #[test]
886    fn the_filtration_thins_as_the_threshold_rises() {
887        let db = SteelDb::ingest(corpus()).unwrap();
888        let levels = db.filtration(4);
889        assert_eq!(levels.len(), 4);
890        // more required agreement can only remove edges
891        for w in levels.windows(2) {
892            assert!(w[1].primal.edges <= w[0].primal.edges, "edges must not grow with s");
893            assert!(w[1].dual.edges <= w[0].dual.edges);
894        }
895    }
896
897    #[test]
898    fn empty_input_is_an_error_not_an_empty_database() {
899        assert!(matches!(SteelDb::ingest(Vec::<String>::new()), Err(Error::Empty(_))));
900        assert!(matches!(SteelDb::ingest(vec!["   ", ""]), Err(Error::Empty(_))));
901    }
902
903    #[test]
904    fn artefacts_make_a_later_ingest_reproducible() {
905        // The pairing that justifies `learn`: the vocabulary is recorded once, and a later run reproduces it
906        // exactly without a model.
907        let dir = std::env::temp_dir().join(format!("hsdb_api_repro_{}", std::process::id()));
908        let _ = std::fs::remove_dir_all(&dir);
909
910        let first = SteelDb::ingest(corpus()).unwrap();
911        first.save(&dir).unwrap();
912        let cat = first.categories()[0].name.to_string();
913        let expected = first.query(&format!("{cat}/*")).unwrap().len();
914
915        let second = SteelDb::ingest_using(corpus(), &dir).unwrap();
916        assert_eq!(
917            second.categories().iter().map(|c| c.name.to_string()).collect::<Vec<_>>(),
918            first.categories().iter().map(|c| c.name.to_string()).collect::<Vec<_>>(),
919            "the recorded vocabulary must be reproduced exactly"
920        );
921        assert_eq!(second.query(&format!("{cat}/*")).unwrap().len(), expected, "and answer identically");
922        let _ = std::fs::remove_dir_all(&dir);
923    }
924
925    #[test]
926    fn saved_artefacts_contain_no_document_text() {
927        // Leakage guard at the API level: what `save` writes must be vocabulary, never the corpus.
928        let dir = std::env::temp_dir().join(format!("hsdb_api_leak_{}", std::process::id()));
929        let _ = std::fs::remove_dir_all(&dir);
930        let db = SteelDb::ingest(corpus()).unwrap();
931        db.save(&dir).unwrap();
932
933        for entry in std::fs::read_dir(&dir).unwrap() {
934            let p = entry.unwrap().path();
935            let text = std::fs::read_to_string(&p).unwrap();
936            for doc in corpus() {
937                assert!(
938                    !text.contains(doc.as_str()),
939                    "{} contains a whole document",
940                    p.display()
941                );
942                // a distinctive multi-word fragment is enough to prove a copy
943                let frag: String = doc.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
944                assert!(!text.contains(&frag), "{} contains the fragment {frag:?}", p.display());
945            }
946        }
947        let _ = std::fs::remove_dir_all(&dir);
948    }
949
950    #[test]
951    fn ingesting_against_a_missing_artefact_set_is_an_error() {
952        let missing = std::env::temp_dir().join("hsdb_definitely_absent_dir");
953        let _ = std::fs::remove_dir_all(&missing);
954        assert!(matches!(
955            SteelDb::ingest_using(corpus(), &missing),
956            Err(Error::Artifact(_))
957        ));
958    }
959
960    #[test]
961    fn a_finetuning_set_is_written_separately_from_the_vocabulary() {
962        let dir = std::env::temp_dir().join(format!("hsdb_api_train_{}", std::process::id()));
963        let _ = std::fs::remove_dir_all(&dir);
964        let db = SteelDb::ingest(corpus()).unwrap();
965        let n = db.save_with_training(&dir).unwrap();
966        assert!(n > 0, "the corpus should yield labelled passages");
967
968        // the committable parts still contain no document text, even though a training set exists
969        for f in ["manifest.json", "vocabulary.json", "gazetteer.json", "relations.json"] {
970            let text = std::fs::read_to_string(dir.join(f)).unwrap();
971            for doc in corpus() {
972                let frag: String = doc.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
973                assert!(!text.contains(&frag), "{f} leaked: {frag:?}");
974            }
975        }
976        // and the training file does carry text, which is the point of keeping it apart
977        let train = std::fs::read_to_string(dir.join("training").join("spans.jsonl")).unwrap();
978        assert!(train.contains("Morty Shade"), "the finetuning set needs the words");
979        assert!(dir.join(".gitignore").exists(), "and must be excluded from commits");
980        let _ = std::fs::remove_dir_all(&dir);
981    }
982
983    #[test]
984    fn training_spans_do_not_overlap() {
985        // Overlapping labels would teach a tagger contradictory boundaries for the same characters.
986        let dir = std::env::temp_dir().join(format!("hsdb_api_ovl_{}", std::process::id()));
987        let _ = std::fs::remove_dir_all(&dir);
988        SteelDb::ingest(corpus()).unwrap().save_with_training(&dir).unwrap();
989        let train = std::fs::read_to_string(dir.join("training").join("spans.jsonl")).unwrap();
990        for line in train.lines().filter(|l| !l.trim().is_empty()) {
991            let v: serde_json::Value = serde_json::from_str(line).unwrap();
992            let spans = v["spans"].as_array().unwrap();
993            let mut last_end = 0u64;
994            for sp in spans {
995                let s = sp["start"].as_u64().unwrap();
996                let e = sp["end"].as_u64().unwrap();
997                assert!(s >= last_end, "span {s}..{e} overlaps the previous one ending at {last_end}");
998                assert!(e > s, "empty span");
999                last_end = e;
1000            }
1001        }
1002        let _ = std::fs::remove_dir_all(&dir);
1003    }
1004
1005    #[test]
1006    fn registered_variants_still_merge_after_an_artefact_reload() {
1007        // The v1 bug: the artefact recorded only surfaces, so a reload re-derived the token by slugging the raw
1008        // text and two variants of one entity stopped sharing a token. Registration NORMALISES, and that has to
1009        // survive the round trip or ingest is not really following the artefacts.
1010        let dir = std::env::temp_dir().join(format!("hsdb_api_canon_{}", std::process::id()));
1011        let _ = std::fs::remove_dir_all(&dir);
1012
1013        let db = SteelDb::ingest(corpus()).unwrap();
1014        db.save(&dir).unwrap();
1015
1016        let set = crate::artifact::Artifacts::load(&dir).unwrap();
1017        assert!(!set.gazetteer.is_empty(), "the corpus should register some mentions");
1018        for r in &set.gazetteer {
1019            assert!(!r.token.is_empty(), "every registration needs a canonical token");
1020            assert!(r.token.contains('/'), "a token is facet-qualified: {}", r.token);
1021        }
1022
1023        // reloading must reproduce the same entity tags, not re-derive different ones
1024        let reloaded = SteelDb::ingest_using(corpus(), &dir).unwrap();
1025        let tags_before: Vec<String> =
1026            db.tags().get("entity").cloned().unwrap_or_default();
1027        let tags_after: Vec<String> =
1028            reloaded.tags().get("entity").cloned().unwrap_or_default();
1029        assert_eq!(tags_before, tags_after, "entity tags must survive the round trip unchanged");
1030        let _ = std::fs::remove_dir_all(&dir);
1031    }
1032
1033    #[test]
1034    fn an_artefact_reload_indexes_identically_to_discovery() {
1035        // The promise `ingest_using` makes is that the expensive step happens once and every run afterwards is
1036        // the same. That only holds if the artefact captures everything the projection needs — motifs were
1037        // missing in schema 2, so a reload silently produced no motif/* tokens and the same documents indexed
1038        // two different ways depending on which path they came through.
1039        let docs = corpus();
1040        let db = SteelDb::ingest(docs.clone()).expect("ingest");
1041
1042        let dir = std::env::temp_dir().join(format!("steeldb-reload-{}", std::process::id()));
1043        let _ = std::fs::remove_dir_all(&dir);
1044        db.save(&dir).expect("save");
1045        let reloaded = SteelDb::ingest_using(docs, &dir).expect("reload");
1046
1047        assert_eq!(db.tags(), reloaded.tags(), "an artefact reload must index identically");
1048        assert_eq!(db.askable(), reloaded.askable());
1049        let _ = std::fs::remove_dir_all(&dir);
1050    }
1051
1052    #[test]
1053    fn a_relation_records_who_was_on_each_side() {
1054        // Bare polarity says a defeat happened; it does not say who won. Without the argument-bound tokens the
1055        // reverse claim matches exactly the same set, which is the one thing a directional relation must not do.
1056        let db = SteelDb::ingest(corpus()).expect("ingest");
1057        let rel = db.tags().get("rel").cloned().unwrap_or_default();
1058
1059        let bound: Vec<&String> = rel.iter().filter(|t| t.matches('/').count() == 3).collect();
1060        assert!(!bound.is_empty(), "no argument-bound relation tokens: {rel:?}");
1061        assert!(
1062            bound.iter().any(|t| t.contains("/+/") ) && bound.iter().any(|t| t.contains("/-/")),
1063            "both sides must be recorded: {bound:?}"
1064        );
1065        // and the mention must survive whole — `shade` instead of `morty-shade` loses the person
1066        assert!(
1067            rel.iter().any(|t| t.ends_with("morty-shade")),
1068            "a name opening a sentence must not be truncated: {rel:?}"
1069        );
1070    }
1071
1072    #[test]
1073    fn a_refusal_stays_readable_rather_than_running_off_the_line() {
1074        // This is the message a user sees at the moment they are confused, so width matters. It listed every
1075        // dimension in the corpus on one line, which came to 122 characters and wrapped wherever the terminal
1076        // happened to end.
1077        let db = SteelDb::ingest(corpus()).unwrap();
1078        let shown = db.query("gene/brca1").unwrap_err().to_string();
1079
1080        for line in shown.lines() {
1081            assert!(line.chars().count() <= 78, "line is {} chars: {line:?}", line.chars().count());
1082        }
1083        // wrapping must not drop or mangle an item
1084        assert!(shown.contains("defeated"), "{shown}");
1085        assert!(!shown.contains(",,") && !shown.contains(" ,"), "mangled list: {shown}");
1086        // a broken line keeps its comma, so the list does not read as if an entry were missing
1087        for line in shown.lines() {
1088            let t = line.trim_end();
1089            if t.ends_with("defeated") || t.ends_with("elevation") {
1090                panic!("a wrapped list line lost its comma: {shown}");
1091            }
1092        }
1093    }
1094
1095    #[test]
1096    fn wrapping_leaves_a_short_message_untouched() {
1097        assert_eq!(wrap_indented("a, b", 70, "  "), "a, b");
1098        assert_eq!(wrap_indented("", 70, "  "), "");
1099        assert_eq!(wrap_indented("single", 2, "  "), "single", "one oversized item cannot be split");
1100    }
1101
1102    #[test]
1103    fn unbalanced_parentheses_are_refused_rather_than_repaired_or_crashed() {
1104        // Two faults found by stressing the published crate. `query(")")` PANICKED: the linter repairs
1105        // parentheses and reported the query clean, then evaluation parsed the raw string and hit an
1106        // `expect("unbalanced )")`. In the wasm build a panic takes the whole module down.
1107        //
1108        // The second fault was quieter and worse. Because the linter repairs, `(and a b` was reported clean and
1109        // then evaluated as `(and a b)` — answering a question the caller had not asked. A repair is a
1110        // suggestion, not a licence to rewrite.
1111        let db = SteelDb::ingest(corpus()).unwrap();
1112
1113        for q in [")", "(", "(and", "))))", "((((", "(and a b", "(or (not x"] {
1114            let e = db
1115                .query(q)
1116                .err()
1117                .unwrap_or_else(|| panic!("{q:?} was answered instead of refused"));
1118            assert!(
1119                e.problems.iter().any(|p| p.contains("unbalanced")),
1120                "{q:?} refused for the wrong reason: {:?}",
1121                e.problems
1122            );
1123            // check() must agree, or one path answers what the other rejects
1124            assert!(db.check(q).is_err(), "check accepted {q:?} while query refused it");
1125        }
1126
1127        // a balanced expression is unaffected
1128        assert!(db.query("state/asserted").is_ok());
1129        assert!(db.query("(not state/negated)").is_ok());
1130    }
1131
1132    #[test]
1133    fn the_parser_never_panics_on_hostile_input() {
1134        // `tokenql::parse` is public, so it is reachable with any string at all.
1135        for q in [")", "((", "()", "\"", "\"unclosed", "(\")\")", "", "(((((((((((((((((((("] {
1136            let _ = crate::tokenql::parse(q);
1137        }
1138    }
1139}