1use 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#[derive(Debug, Clone, Default)]
40pub struct Answer {
41 ids: Vec<u32>,
42 micros: f64,
43}
44
45impl Answer {
46 pub fn len(&self) -> usize {
48 self.ids.len()
49 }
50 pub fn is_empty(&self) -> bool {
51 self.ids.is_empty()
52 }
53 pub fn ids(&self) -> &[u32] {
55 &self.ids
56 }
57 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#[derive(Debug, Clone)]
84pub struct Refused {
85 pub query: String,
87 pub problems: Vec<String>,
89 pub alternatives: Vec<String>,
91}
92
93fn 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 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#[derive(Debug, Clone, Copy, PartialEq)]
138pub struct Interval {
139 pub belief: f64,
141 pub plausibility: f64,
143}
144
145impl Interval {
146 pub fn ignorance(&self) -> f64 {
148 (self.plausibility - self.belief).max(0.0)
149 }
150 pub fn is_certain(&self) -> bool {
152 self.belief >= 1.0 - f64::EPSILON
153 }
154 pub fn is_refuted(&self) -> bool {
156 self.plausibility <= f64::EPSILON
157 }
158 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#[derive(Debug, Clone, Copy)]
172pub struct Category<'a> {
173 pub name: &'a str,
175 pub words: &'a [String],
177}
178
179impl Category<'_> {
180 pub fn wildcard(&self) -> String {
182 format!("{}/*", self.name)
183 }
184}
185
186#[derive(Debug)]
188pub enum Error {
189 Empty(String),
191 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#[derive(Debug, Clone)]
218pub struct Options {
219 pub terms: usize,
221 pub categories: usize,
223 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
233pub struct SteelDb {
237 corpus: Corpus,
238 categories: Vec<(String, Vec<String>)>,
239 motifs: Vec<(String, Vec<String>)>,
241 documents: Vec<String>,
242 min_gain: f64,
244}
245
246const MOTIF_TERMS: usize = 40;
249const MOTIF_GROUPS: usize = 3;
251
252impl SteelDb {
253 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 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 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 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 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 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 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 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 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; }
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 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 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 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 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 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 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 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 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 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 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 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 pub fn check(&self, ikl: &str) -> Result<(), Refused> {
659 let report = self.corpus.linter().lint(ikl);
660 if let Some(fixed) = &report.repaired {
661 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 pub fn belief(&self, tag: &str) -> Interval {
688 let (belief, plausibility) = self.corpus.belief_interval(tag);
689 Interval { belief, plausibility }
690 }
691
692 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 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 pub fn categories(&self) -> Vec<Category<'_>> {
713 self.categories.iter().map(|(name, words)| Category { name, words }).collect()
714 }
715
716 pub fn askable(&self) -> Vec<String> {
718 self.categories.iter().map(|(c, _)| format!("{c}/*")).collect()
719 }
720
721 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 pub fn text(&self, situation: u32) -> Option<&str> {
743 self.documents.get(situation as usize).map(|s| s.as_str())
744 }
745
746 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 pub fn len(&self) -> usize {
753 self.documents.len()
754 }
755 pub fn is_empty(&self) -> bool {
756 self.documents.is_empty()
757 }
758 pub fn documents(&self) -> &[String] {
760 &self.documents
761 }
762
763 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 #[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 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 assert!(answer.ids().iter().all(|id| (*id as usize) < db.len()));
852 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(shown.contains("defeated"), "{shown}");
1085 assert!(!shown.contains(",,") && !shown.contains(" ,"), "mangled list: {shown}");
1086 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 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 assert!(db.check(q).is_err(), "check accepted {q:?} while query refused it");
1125 }
1126
1127 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 for q in [")", "((", "()", "\"", "\"unclosed", "(\")\")", " ", "(((((((((((((((((((("] {
1136 let _ = crate::tokenql::parse(q);
1137 }
1138 }
1139}