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 #[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#[derive(Debug, Clone)]
223pub struct Options {
224 pub terms: usize,
226 pub categories: usize,
228 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
238struct Projected {
240 tags: Vec<String>,
241 display: Vec<String>,
242 numbers: Vec<(String, f64)>,
243 beliefs: Vec<(String, f32)>,
244}
245
246pub struct SteelDb {
250 corpus: Corpus,
251 categories: Vec<(String, Vec<String>)>,
252 motifs: Vec<(String, Vec<String>)>,
254 registrations: Vec<crate::artifact::Registration>,
258 documents: Vec<String>,
259 min_gain: f64,
261}
262
263const MOTIF_TERMS: usize = 40;
266const MOTIF_GROUPS: usize = 3;
268
269impl SteelDb {
270 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 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 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 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, ®istrations, &motifs);
339 Ok(SteelDb { corpus, categories, motifs, registrations, documents, min_gain: opts.min_gain })
340 }
341
342 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 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 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 if !surfaces.is_empty() {
433 let matcher = crate::emergent::MentionMatcher::new(surfaces);
434 if !documents.iter().any(|d| !matcher.present(d).is_empty()) {
435 return Err(Error::Empty(format!(
436 "none of the {} registered surfaces occurs in any of the {} documents — the curated \
437 ontology and the surface list look like they came from different corpora",
438 surfaces.len(),
439 documents.len()
440 )));
441 }
442 }
443
444 let registrations: Vec<crate::artifact::Registration> = surfaces
446 .iter()
447 .map(|surface| crate::artifact::Registration {
448 surface: surface.clone(),
449 token: format!("entity/{}", crate::projector::slug(surface)),
450 })
451 .collect();
452 let corpus = Self::project_with(&documents, &categories, ®istrations, &motifs);
453 Ok(SteelDb { corpus, categories, motifs, registrations, documents, min_gain })
454 }
455
456 pub fn ingest_using<I, S>(docs: I, artifact_dir: impl AsRef<Path>) -> Result<Self, Error>
463 where
464 I: IntoIterator<Item = S>,
465 S: AsRef<str>,
466 {
467 let set = crate::artifact::Artifacts::load(artifact_dir).map_err(Error::Artifact)?;
468 let documents: Vec<String> =
469 docs.into_iter().map(|d| d.as_ref().trim().to_string()).filter(|d| !d.is_empty()).collect();
470 if documents.is_empty() {
471 return Err(Error::Empty("no non-empty documents".into()));
472 }
473 let categories: Vec<(String, Vec<String>)> =
474 set.categories.into_iter().map(|c| (c.name, c.words)).collect();
475 let motifs: Vec<(String, Vec<String>)> =
478 set.motifs.into_iter().map(|m| (m.name, m.words)).collect();
479 let corpus = Self::project_with(&documents, &categories, &set.gazetteer, &motifs);
480 Ok(SteelDb {
481 corpus,
482 categories,
483 motifs,
484 registrations: set.gazetteer,
485 documents,
486 min_gain: Options::default().min_gain,
487 })
488 }
489
490 pub fn save(&self, artifact_dir: impl AsRef<Path>) -> Result<(), Error> {
495 let categories = self
496 .categories
497 .iter()
498 .map(|(name, words)| crate::artifact::CategoryRecord {
499 name: name.clone(),
500 words: words.clone(),
501 })
502 .collect();
503 let registrations = self.registrations.clone();
506 let surfaces: Vec<String> = registrations.iter().map(|r| r.surface.clone()).collect();
507 let matcher = crate::emergent::MentionMatcher::new(&surfaces);
508 let mut relations: Vec<String> = Vec::new();
509 for doc in &self.documents {
510 for r in crate::emergent::relation_spans_with(doc, &matcher) {
511 if !relations.contains(&r.verb) {
512 relations.push(r.verb);
513 }
514 }
515 }
516 let motifs = self
517 .motifs
518 .iter()
519 .map(|(name, words)| crate::artifact::CategoryRecord {
520 name: name.clone(),
521 words: words.clone(),
522 })
523 .collect();
524 crate::artifact::Artifacts::new("discovery", categories, registrations, relations)
525 .with_motifs(motifs)
526 .save(artifact_dir)
527 .map_err(Error::Artifact)
528 }
529
530 pub fn save_with_training(&self, artifact_dir: impl AsRef<Path>) -> Result<usize, Error> {
541 let gazetteer: Vec<String> = self.registrations.iter().map(|r| r.surface.clone()).collect();
543 let mut jsonl = String::new();
544 for doc in &self.documents {
545 let mut spans: Vec<serde_json::Value> = Vec::new();
546 let mut push = |s: usize, e: usize, facet: &str| {
547 if let Some(surface) = doc.get(s..e) {
548 spans.push(serde_json::json!({
549 "start": s, "end": e, "facet": facet, "surface": surface,
550 }));
551 }
552 };
553 for m in &gazetteer {
554 for (s, e) in crate::emergent::word_spans(doc, m) {
555 push(s, e, "entity");
556 }
557 }
558 for (s, e, field) in crate::emergent::quantity_spans(doc) {
559 push(s, e, &format!("qty/{field}"));
560 }
561 for (s, e, tok) in crate::emergent::temporal_spans(doc) {
562 let _ = tok;
563 push(s, e, "time");
564 }
565 for (cat, words) in &self.categories {
566 for w in words {
567 for (s, e) in crate::emergent::word_spans(doc, w) {
568 push(s, e, cat);
569 }
570 }
571 }
572 spans.sort_by_key(|v| (v["start"].as_u64().unwrap_or(0), v["end"].as_u64().unwrap_or(0)));
574 let mut kept: Vec<serde_json::Value> = Vec::new();
575 let mut cursor = 0u64;
576 for sp in spans {
577 let (s, e) = (sp["start"].as_u64().unwrap_or(0), sp["end"].as_u64().unwrap_or(0));
578 if s >= cursor {
579 cursor = e;
580 kept.push(sp);
581 }
582 }
583 if kept.is_empty() {
584 continue; }
586 let line = serde_json::json!({ "text": doc, "spans": kept });
587 jsonl.push_str(&line.to_string());
588 jsonl.push('\n');
589 }
590
591 let categories = self
592 .categories
593 .iter()
594 .map(|(name, words)| crate::artifact::CategoryRecord {
595 name: name.clone(),
596 words: words.clone(),
597 })
598 .collect();
599 let mut relations: Vec<String> = Vec::new();
600 for doc in &self.documents {
601 for r in crate::emergent::relation_spans(doc, &gazetteer) {
602 if !relations.contains(&r.verb) {
603 relations.push(r.verb);
604 }
605 }
606 }
607 let set = crate::artifact::Artifacts::new(
608 "discovery",
609 categories,
610 self.registrations.clone(),
611 relations,
612 )
613 .with_motifs(
614 self.motifs
615 .iter()
616 .map(|(name, words)| crate::artifact::CategoryRecord {
617 name: name.clone(),
618 words: words.clone(),
619 })
620 .collect(),
621 )
622 .with_training(jsonl);
623 let n = set.training_examples;
624 set.save(artifact_dir).map_err(Error::Artifact)?;
625 Ok(n)
626 }
627
628 pub fn open(dir: impl AsRef<Path>) -> Result<Self, Error> {
630 let dir = dir.as_ref();
631 let mut docs: Vec<String> = Vec::new();
632 for entry in std::fs::read_dir(dir)? {
633 let path = entry?.path();
634 if path.extension().map(|e| e == "md" || e == "txt").unwrap_or(false) {
635 if let Ok(text) = std::fs::read_to_string(&path) {
636 docs.push(text);
637 }
638 }
639 }
640 if docs.is_empty() {
641 return Err(Error::Empty(format!("no .md or .txt files under {}", dir.display())));
642 }
643 Self::ingest(docs)
644 }
645
646 fn registrations(docs: &[String]) -> Vec<crate::artifact::Registration> {
648 crate::emergent::mine_gazetteer(docs, 2)
649 .into_iter()
650 .map(|surface| {
651 let token = format!("entity/{}", crate::projector::slug(&surface));
652 crate::artifact::Registration { surface, token }
653 })
654 .collect()
655 }
656
657 fn project_with(
660 docs: &[String],
661 categories: &[(String, Vec<String>)],
662 registrations: &[crate::artifact::Registration],
663 motifs: &[(String, Vec<String>)],
664 ) -> Corpus {
665 let gazetteer: Vec<String> = registrations.iter().map(|r| r.surface.clone()).collect();
666 let canonical: std::collections::HashMap<&str, &str> =
668 registrations.iter().map(|r| (r.surface.as_str(), r.token.as_str())).collect();
669 let matcher = crate::emergent::MentionMatcher::new(&gazetteer);
672
673 let projected: Vec<Projected> = Self::project_docs_parallel(docs, &matcher, &canonical, categories, motifs);
680
681 let mut corpus = Corpus::new_incremental("documents", vec!["document".into()], CorpusKind::Text);
682 for p in projected {
683 corpus.add_situation_polar(p.tags, p.display, p.numbers, p.beliefs);
684 }
685 corpus
686 }
687
688 fn project_docs_parallel(
691 docs: &[String],
692 matcher: &crate::emergent::MentionMatcher,
693 canonical: &std::collections::HashMap<&str, &str>,
694 categories: &[(String, Vec<String>)],
695 motifs: &[(String, Vec<String>)],
696 ) -> Vec<Projected> {
697 #[cfg(not(target_arch = "wasm32"))]
698 {
699 let workers = std::thread::available_parallelism()
701 .map(|n| n.get())
702 .unwrap_or(6)
703 .max(6)
704 .min(docs.len().max(1));
705 if workers > 1 && docs.len() > 1 {
706 let chunk = docs.len().div_ceil(workers);
707 let mut chunks: Vec<Vec<Projected>> = Vec::new();
708 std::thread::scope(|scope| {
709 let handles: Vec<_> = docs
710 .chunks(chunk)
711 .map(|slice| {
712 scope.spawn(move || {
713 slice
714 .iter()
715 .map(|d| Self::project_doc(d, matcher, canonical, categories, motifs))
716 .collect::<Vec<_>>()
717 })
718 })
719 .collect();
720 for h in handles {
722 chunks.push(h.join().expect("projection worker panicked"));
723 }
724 });
725 return chunks.into_iter().flatten().collect();
726 }
727 }
728 docs.iter().map(|d| Self::project_doc(d, matcher, canonical, categories, motifs)).collect()
729 }
730
731 fn project_doc(
734 doc: &str,
735 matcher: &crate::emergent::MentionMatcher,
736 canonical: &std::collections::HashMap<&str, &str>,
737 categories: &[(String, Vec<String>)],
738 motifs: &[(String, Vec<String>)],
739 ) -> Projected {
740 let mut tags: Vec<String> = Vec::new();
741 let mut numbers: Vec<(String, f64)> = Vec::new();
742 let lower = doc.to_lowercase();
746 let level = crate::dimensions::belief_level(
747 crate::dimensions::denies_claim(&lower),
748 lower.contains("under review") || lower.contains("may be") || lower.contains("provisional"),
749 );
750
751 for mention in matcher.present(doc) {
752 match canonical.get(mention.as_str()) {
754 Some(tok) => tags.push((*tok).to_string()),
755 None => tags.push(format!("entity/{}", crate::projector::slug(mention))),
756 }
757 }
758 for r in crate::emergent::relation_spans_with(doc, matcher) {
759 let verb = crate::projector::slug(&r.verb);
760 tags.push(format!("rel/{verb}/+"));
765 tags.push(format!("rel/{verb}/+/{}", crate::projector::slug(&r.actor)));
766 tags.push(format!("rel/{verb}/-"));
767 tags.push(format!("rel/{verb}/-/{}", crate::projector::slug(&r.target)));
768 }
769 for (_, _, tok) in crate::emergent::temporal_spans(doc) {
770 tags.push(tok);
771 }
772 for (st, en, field) in crate::emergent::quantity_spans(doc) {
773 tags.push(format!("quantity/{field}"));
774 let digits: String = doc[st..en]
775 .chars()
776 .enumerate()
777 .take_while(|(i, c)| c.is_ascii_digit() || *c == '.' || (*i == 0 && *c == '-'))
778 .map(|(_, c)| c)
779 .collect();
780 if let Ok(v) = digits.parse::<f64>() {
781 numbers.push((field, v));
782 }
783 }
784 for (cat, terms) in categories {
785 for t in terms {
786 if crate::emergent::contains_term(doc, t) {
787 tags.push(format!("{cat}/{}", crate::projector::slug(t)));
788 }
789 }
790 }
791 for (name, terms) in motifs {
794 if terms.iter().any(|t| crate::emergent::contains_term(doc, t)) {
795 tags.push(format!("motif/{}", crate::projector::slug(name)));
796 }
797 }
798 tags.push(
799 match level {
800 l if l < 0.0 => "state/negated",
801 l if l < 1.0 => "state/hedged",
802 _ => "state/asserted",
803 }
804 .to_string(),
805 );
806
807 let beliefs: Vec<(String, f32)> = tags.iter().map(|t| (t.clone(), level)).collect();
808 let display = vec![doc.chars().take(160).collect::<String>()];
809 numbers.dedup_by(|a, b| a.0 == b.0);
810 Projected { tags, display, numbers, beliefs }
811 }
812
813 pub fn query(&self, ikl: &str) -> Result<Answer, Refused> {
818 let report = self.corpus.linter().lint(ikl);
819 if let Some(fixed) = &report.repaired {
820 return Err(Refused {
825 query: ikl.to_string(),
826 problems: vec![if fixed.trim().is_empty() {
827 "unbalanced parentheses: a ')' with no matching '(' leaves nothing to run".into()
828 } else {
829 format!("unbalanced parentheses; did you mean: {fixed}")
830 }],
831 alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
832 });
833 }
834 if !report.ok {
835 return Err(Refused {
836 query: ikl.to_string(),
837 problems: report.errors.iter().map(|e| e.message.clone()).collect(),
838 alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
839 });
840 }
841 match crate::tokenql::try_evaluate(self.corpus.index(), ikl) {
842 Ok(set) => Ok(Answer { ids: set.to_sorted(), micros: 0.0 }),
843 Err(e) => Err(Refused {
844 query: ikl.to_string(),
845 problems: vec![e.to_string()],
846 alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
847 }),
848 }
849 }
850
851 pub fn check(&self, ikl: &str) -> Result<(), Refused> {
853 let report = self.corpus.linter().lint(ikl);
854 if let Some(fixed) = &report.repaired {
855 return Err(Refused {
860 query: ikl.to_string(),
861 problems: vec![if fixed.trim().is_empty() {
862 "unbalanced parentheses: a ')' with no matching '(' leaves nothing to run".into()
863 } else {
864 format!("unbalanced parentheses; did you mean: {fixed}")
865 }],
866 alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
867 });
868 }
869 if report.ok {
870 Ok(())
871 } else {
872 Err(Refused {
873 query: ikl.to_string(),
874 problems: report.errors.iter().map(|e| e.message.clone()).collect(),
875 alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
876 })
877 }
878 }
879
880 pub fn belief(&self, tag: &str) -> Interval {
882 let (belief, plausibility) = self.corpus.belief_interval(tag);
883 Interval { belief, plausibility }
884 }
885
886 pub fn s_path(&self, from: &str, to: &str, s: usize) -> Answer {
890 let set = self.corpus.index().s_path_tokens(from, to, s).map(|chain| {
891 let mut out = P::empty();
892 for tok in &chain {
893 out.or_inplace(&crate::tokenql::TokenStore::atom(self.corpus.index(), tok));
894 }
895 out
896 });
897 Answer { ids: set.map(|s| s.to_sorted()).unwrap_or_default(), micros: 0.0 }
898 }
899
900 pub fn filtration(&self, max_s: usize) -> Vec<crate::programs::Level> {
902 crate::programs::s_filtration(self.corpus.index(), max_s, &Default::default(), 128)
903 }
904
905 pub fn categories(&self) -> Vec<Category<'_>> {
907 self.categories.iter().map(|(name, words)| Category { name, words }).collect()
908 }
909
910 pub fn askable(&self) -> Vec<String> {
912 self.categories.iter().map(|(c, _)| format!("{c}/*")).collect()
913 }
914
915 pub fn tags(&self) -> BTreeMap<String, Vec<String>> {
920 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
921 for tag in self.corpus.index().tokens() {
922 let stem = tag.split('/').next().unwrap_or("").to_string();
923 out.entry(stem).or_default().push(tag.clone());
924 }
925 for v in out.values_mut() {
926 v.sort();
927 v.dedup();
928 }
929 out
930 }
931
932 pub fn text(&self, situation: u32) -> Option<&str> {
937 self.documents.get(situation as usize).map(|s| s.as_str())
938 }
939
940 pub fn resolve<'a>(&'a self, answer: &'a Answer) -> impl Iterator<Item = (u32, &'a str)> + 'a {
942 answer.ids().iter().filter_map(move |id| self.text(*id).map(|t| (*id, t)))
943 }
944
945 pub fn len(&self) -> usize {
947 self.documents.len()
948 }
949 pub fn is_empty(&self) -> bool {
950 self.documents.is_empty()
951 }
952 pub fn documents(&self) -> &[String] {
954 &self.documents
955 }
956
957 pub(crate) fn spec_snapshot(&self) -> crate::vocabulary::VocabularySpace {
961 crate::vocabulary::VocabularySpace {
962 version: 1,
963 corpus: "documents".into(),
964 entity_facets: self
965 .categories
966 .iter()
967 .map(|(name, words)| crate::vocabulary::EntityFacet {
968 name: name.clone(),
969 parent: None,
970 description: String::new(),
971 examples: words.clone(),
972 structural: false,
973 })
974 .collect(),
975 relation_facets: Vec::new(),
976 gazetteer: Vec::new(),
977 metrics: None,
978 }
979 }
980
981 #[cfg(feature = "wasm")]
984 pub(crate) fn into_corpus(self) -> Corpus {
985 self.corpus
986 }
987
988 pub(crate) fn min_gain(&self) -> f64 {
989 self.min_gain
990 }
991
992 pub(crate) fn push_category(&mut self, name: String, words: Vec<String>) {
993 self.categories.push((name, words));
994 }
995
996 pub(crate) fn reproject(&mut self) {
999 self.corpus = Self::project_with(&self.documents, &self.categories, &self.registrations, &self.motifs);
1002 }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::*;
1008
1009 fn corpus() -> Vec<String> {
1010 [
1011 "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational in 2025.",
1012 "Bea Strike defeated Iris Draco at Ecruteak City during the Indigo Invitational in 2025.",
1013 "A habitat survey recorded Aggron near Sootopolis City at an elevation of 1082 m.",
1014 "A habitat survey recorded Salamence near Sootopolis City at an elevation of 2369 m.",
1015 "Milotic is not permitted in Series 1 play for the 2025 season.",
1016 "Metagross is permitted in Series 4 play for the 2026 season.",
1017 ]
1018 .iter()
1019 .map(|s| s.to_string())
1020 .collect()
1021 }
1022
1023 #[test]
1024 fn three_lines_to_a_working_database() {
1025 let db = SteelDb::ingest(corpus()).expect("index");
1026 assert_eq!(db.len(), 6);
1027 assert!(!db.categories().is_empty(), "should discover at least one category");
1028 }
1029
1030 #[test]
1031 fn an_unsupported_query_is_refused_with_alternatives() {
1032 let db = SteelDb::ingest(corpus()).unwrap();
1033 let err = db.query("gene/brca1").expect_err("must refuse a category the data lacks");
1034 assert!(!err.alternatives.is_empty(), "a refusal must say what does exist");
1035 let shown = err.to_string();
1036 assert!(shown.contains("refused"), "{shown}");
1037 assert!(shown.contains("available"), "{shown}");
1038 }
1039
1040 #[test]
1041 fn a_supported_query_returns_a_complete_set() {
1042 let db = SteelDb::ingest(corpus()).unwrap();
1043 let cat = db.categories()[0].name.to_string();
1044 let answer = db.query(&format!("{cat}/*")).expect("a discovered category must be queryable");
1045 assert!(!answer.is_empty());
1046 assert!(answer.ids().iter().all(|id| (*id as usize) < db.len()));
1048 assert_eq!(answer.ids().len(), (&answer).into_iter().count());
1050 }
1051
1052 #[test]
1053 fn negation_narrows_rather_than_widens() {
1054 let db = SteelDb::ingest(corpus()).unwrap();
1055 let cat = db.categories()[0].name.to_string();
1056 let all = db.query(&format!("{cat}/*")).unwrap().len();
1057 let some = db.query(&format!("(and {cat}/* (not state/negated))")).unwrap().len();
1058 assert!(some <= all, "excluding something cannot return more: {some} vs {all}");
1059 }
1060
1061 #[test]
1062 fn belief_separates_asserted_from_negated() {
1063 let db = SteelDb::ingest(corpus()).unwrap();
1064 let asserted = db.belief("state/asserted");
1065 let negated = db.belief("state/negated");
1066 assert!(asserted.belief > negated.belief, "{asserted} vs {negated}");
1067 assert!(asserted.ignorance() >= 0.0);
1069 assert!(db.belief("state/nonexistent").is_unknown(), "an absent tag is unknown, not refuted");
1070 }
1071
1072 #[test]
1073 fn check_costs_nothing_and_agrees_with_query() {
1074 let db = SteelDb::ingest(corpus()).unwrap();
1075 assert!(db.check("gene/brca1").is_err());
1076 assert!(db.query("gene/brca1").is_err());
1077 let cat = db.categories()[0].name.to_string();
1078 assert!(db.check(&format!("{cat}/*")).is_ok());
1079 }
1080
1081 #[test]
1082 fn the_filtration_thins_as_the_threshold_rises() {
1083 let db = SteelDb::ingest(corpus()).unwrap();
1084 let levels = db.filtration(4);
1085 assert_eq!(levels.len(), 4);
1086 for w in levels.windows(2) {
1088 assert!(w[1].primal.edges <= w[0].primal.edges, "edges must not grow with s");
1089 assert!(w[1].dual.edges <= w[0].dual.edges);
1090 }
1091 }
1092
1093 #[test]
1094 fn empty_input_is_an_error_not_an_empty_database() {
1095 assert!(matches!(SteelDb::ingest(Vec::<String>::new()), Err(Error::Empty(_))));
1096 assert!(matches!(SteelDb::ingest(vec![" ", ""]), Err(Error::Empty(_))));
1097 }
1098
1099 #[test]
1100 fn artefacts_make_a_later_ingest_reproducible() {
1101 let dir = std::env::temp_dir().join(format!("hsdb_api_repro_{}", std::process::id()));
1104 let _ = std::fs::remove_dir_all(&dir);
1105
1106 let first = SteelDb::ingest(corpus()).unwrap();
1107 first.save(&dir).unwrap();
1108 let cat = first.categories()[0].name.to_string();
1109 let expected = first.query(&format!("{cat}/*")).unwrap().len();
1110
1111 let second = SteelDb::ingest_using(corpus(), &dir).unwrap();
1112 assert_eq!(
1113 second.categories().iter().map(|c| c.name.to_string()).collect::<Vec<_>>(),
1114 first.categories().iter().map(|c| c.name.to_string()).collect::<Vec<_>>(),
1115 "the recorded vocabulary must be reproduced exactly"
1116 );
1117 assert_eq!(second.query(&format!("{cat}/*")).unwrap().len(), expected, "and answer identically");
1118 let _ = std::fs::remove_dir_all(&dir);
1119 }
1120
1121 #[test]
1122 fn saved_artefacts_contain_no_document_text() {
1123 let dir = std::env::temp_dir().join(format!("hsdb_api_leak_{}", std::process::id()));
1125 let _ = std::fs::remove_dir_all(&dir);
1126 let db = SteelDb::ingest(corpus()).unwrap();
1127 db.save(&dir).unwrap();
1128
1129 for entry in std::fs::read_dir(&dir).unwrap() {
1130 let p = entry.unwrap().path();
1131 let text = std::fs::read_to_string(&p).unwrap();
1132 for doc in corpus() {
1133 assert!(
1134 !text.contains(doc.as_str()),
1135 "{} contains a whole document",
1136 p.display()
1137 );
1138 let frag: String = doc.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
1140 assert!(!text.contains(&frag), "{} contains the fragment {frag:?}", p.display());
1141 }
1142 }
1143 let _ = std::fs::remove_dir_all(&dir);
1144 }
1145
1146 #[test]
1147 fn ingesting_against_a_missing_artefact_set_is_an_error() {
1148 let missing = std::env::temp_dir().join("hsdb_definitely_absent_dir");
1149 let _ = std::fs::remove_dir_all(&missing);
1150 assert!(matches!(
1151 SteelDb::ingest_using(corpus(), &missing),
1152 Err(Error::Artifact(_))
1153 ));
1154 }
1155
1156 #[test]
1157 fn a_finetuning_set_is_written_separately_from_the_vocabulary() {
1158 let dir = std::env::temp_dir().join(format!("hsdb_api_train_{}", std::process::id()));
1159 let _ = std::fs::remove_dir_all(&dir);
1160 let db = SteelDb::ingest(corpus()).unwrap();
1161 let n = db.save_with_training(&dir).unwrap();
1162 assert!(n > 0, "the corpus should yield labelled passages");
1163
1164 for f in ["manifest.json", "vocabulary.json", "gazetteer.json", "relations.json"] {
1166 let text = std::fs::read_to_string(dir.join(f)).unwrap();
1167 for doc in corpus() {
1168 let frag: String = doc.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
1169 assert!(!text.contains(&frag), "{f} leaked: {frag:?}");
1170 }
1171 }
1172 let train = std::fs::read_to_string(dir.join("training").join("spans.jsonl")).unwrap();
1174 assert!(train.contains("Morty Shade"), "the finetuning set needs the words");
1175 assert!(dir.join(".gitignore").exists(), "and must be excluded from commits");
1176 let _ = std::fs::remove_dir_all(&dir);
1177 }
1178
1179 #[test]
1180 fn training_spans_do_not_overlap() {
1181 let dir = std::env::temp_dir().join(format!("hsdb_api_ovl_{}", std::process::id()));
1183 let _ = std::fs::remove_dir_all(&dir);
1184 SteelDb::ingest(corpus()).unwrap().save_with_training(&dir).unwrap();
1185 let train = std::fs::read_to_string(dir.join("training").join("spans.jsonl")).unwrap();
1186 for line in train.lines().filter(|l| !l.trim().is_empty()) {
1187 let v: serde_json::Value = serde_json::from_str(line).unwrap();
1188 let spans = v["spans"].as_array().unwrap();
1189 let mut last_end = 0u64;
1190 for sp in spans {
1191 let s = sp["start"].as_u64().unwrap();
1192 let e = sp["end"].as_u64().unwrap();
1193 assert!(s >= last_end, "span {s}..{e} overlaps the previous one ending at {last_end}");
1194 assert!(e > s, "empty span");
1195 last_end = e;
1196 }
1197 }
1198 let _ = std::fs::remove_dir_all(&dir);
1199 }
1200
1201 #[test]
1202 fn registered_variants_still_merge_after_an_artefact_reload() {
1203 let dir = std::env::temp_dir().join(format!("hsdb_api_canon_{}", std::process::id()));
1207 let _ = std::fs::remove_dir_all(&dir);
1208
1209 let db = SteelDb::ingest(corpus()).unwrap();
1210 db.save(&dir).unwrap();
1211
1212 let set = crate::artifact::Artifacts::load(&dir).unwrap();
1213 assert!(!set.gazetteer.is_empty(), "the corpus should register some mentions");
1214 for r in &set.gazetteer {
1215 assert!(!r.token.is_empty(), "every registration needs a canonical token");
1216 assert!(r.token.contains('/'), "a token is facet-qualified: {}", r.token);
1217 }
1218
1219 let reloaded = SteelDb::ingest_using(corpus(), &dir).unwrap();
1221 let tags_before: Vec<String> =
1222 db.tags().get("entity").cloned().unwrap_or_default();
1223 let tags_after: Vec<String> =
1224 reloaded.tags().get("entity").cloned().unwrap_or_default();
1225 assert_eq!(tags_before, tags_after, "entity tags must survive the round trip unchanged");
1226 let _ = std::fs::remove_dir_all(&dir);
1227 }
1228
1229 #[test]
1230 fn parallel_projection_preserves_document_order() {
1231 let docs: Vec<String> = (0..40)
1235 .map(|i| format!("Trainer{i} Shade defeated Rival{i} Gale at Ecruteak City in 2025."))
1236 .collect();
1237 let db = SteelDb::ingest(docs.clone()).expect("ingest");
1238 assert_eq!(db.len(), 40);
1239 for (i, doc) in docs.iter().enumerate() {
1240 let shown = db.text(i as u32).expect("every situation resolves");
1241 let head: String = doc.chars().take(20).collect();
1242 assert!(shown.starts_with(&head), "situation {i} shows {shown:?}, not document {i} ({head:?})");
1243 }
1244 }
1245
1246 #[test]
1247 fn ingest_is_deterministic_under_parallelism() {
1248 let docs: Vec<String> = (0..40)
1251 .map(|i| {
1252 if i % 2 == 0 {
1253 format!("Trainer{i} defeated Rival{i} at Ecruteak City in 2025.")
1254 } else {
1255 format!("A survey recorded Aggron near Sootopolis City at {} m.", 600 + i)
1256 }
1257 })
1258 .collect();
1259 let a = SteelDb::ingest(docs.clone()).expect("a");
1260 let b = SteelDb::ingest(docs).expect("b");
1261 assert_eq!(a.tags(), b.tags(), "parallel ingest is not deterministic");
1262 assert_eq!(a.askable(), b.askable());
1263 }
1264
1265 #[test]
1266 fn an_artefact_reload_indexes_identically_to_discovery() {
1267 let docs = corpus();
1272 let db = SteelDb::ingest(docs.clone()).expect("ingest");
1273
1274 let dir = std::env::temp_dir().join(format!("steeldb-reload-{}", std::process::id()));
1275 let _ = std::fs::remove_dir_all(&dir);
1276 db.save(&dir).expect("save");
1277 let reloaded = SteelDb::ingest_using(docs, &dir).expect("reload");
1278
1279 assert_eq!(db.tags(), reloaded.tags(), "an artefact reload must index identically");
1280 assert_eq!(db.askable(), reloaded.askable());
1281 let _ = std::fs::remove_dir_all(&dir);
1282 }
1283
1284 #[test]
1285 fn a_relation_records_who_was_on_each_side() {
1286 let db = SteelDb::ingest(corpus()).expect("ingest");
1289 let rel = db.tags().get("rel").cloned().unwrap_or_default();
1290
1291 let bound: Vec<&String> = rel.iter().filter(|t| t.matches('/').count() == 3).collect();
1292 assert!(!bound.is_empty(), "no argument-bound relation tokens: {rel:?}");
1293 assert!(
1294 bound.iter().any(|t| t.contains("/+/") ) && bound.iter().any(|t| t.contains("/-/")),
1295 "both sides must be recorded: {bound:?}"
1296 );
1297 assert!(
1299 rel.iter().any(|t| t.ends_with("morty-shade")),
1300 "a name opening a sentence must not be truncated: {rel:?}"
1301 );
1302 }
1303
1304 #[test]
1305 fn a_refusal_stays_readable_rather_than_running_off_the_line() {
1306 let db = SteelDb::ingest(corpus()).unwrap();
1310 let shown = db.query("gene/brca1").unwrap_err().to_string();
1311
1312 for line in shown.lines() {
1313 assert!(line.chars().count() <= 78, "line is {} chars: {line:?}", line.chars().count());
1314 }
1315 assert!(shown.contains("defeated"), "{shown}");
1317 assert!(!shown.contains(",,") && !shown.contains(" ,"), "mangled list: {shown}");
1318 for line in shown.lines() {
1320 let t = line.trim_end();
1321 if t.ends_with("defeated") || t.ends_with("elevation") {
1322 panic!("a wrapped list line lost its comma: {shown}");
1323 }
1324 }
1325 }
1326
1327 #[test]
1328 fn wrapping_leaves_a_short_message_untouched() {
1329 assert_eq!(wrap_indented("a, b", 70, " "), "a, b");
1330 assert_eq!(wrap_indented("", 70, " "), "");
1331 assert_eq!(wrap_indented("single", 2, " "), "single", "one oversized item cannot be split");
1332 }
1333
1334 #[test]
1335 fn unbalanced_parentheses_are_refused_rather_than_repaired_or_crashed() {
1336 let db = SteelDb::ingest(corpus()).unwrap();
1344
1345 for q in [")", "(", "(and", "))))", "((((", "(and a b", "(or (not x"] {
1346 let e = db
1347 .query(q)
1348 .err()
1349 .unwrap_or_else(|| panic!("{q:?} was answered instead of refused"));
1350 assert!(
1351 e.problems.iter().any(|p| p.contains("unbalanced")),
1352 "{q:?} refused for the wrong reason: {:?}",
1353 e.problems
1354 );
1355 assert!(db.check(q).is_err(), "check accepted {q:?} while query refused it");
1357 }
1358
1359 assert!(db.query("state/asserted").is_ok());
1361 assert!(db.query("(not state/negated)").is_ok());
1362 }
1363
1364 #[test]
1365 fn the_parser_never_panics_on_hostile_input() {
1366 for q in [")", "((", "()", "\"", "\"unclosed", "(\")\")", "\0", "(((((((((((((((((((("] {
1368 let _ = crate::tokenql::parse(q);
1369 }
1370 }
1371
1372 #[test]
1373 fn a_curated_ontology_paired_with_the_wrong_surfaces_is_refused() {
1374 use crate::learn::{Candidate, Proposal};
1378 let curated = Proposal {
1379 source: "test".into(),
1380 candidates: vec![Candidate {
1381 name: "ruling".into(),
1382 words: vec!["permitted".into(), "season".into()],
1383 rationale: String::new(),
1384 }],
1385 };
1386
1387 let wrong = ["Zzyzx Consolidated".to_string(), "Qqqq Holdings".to_string()];
1389 let err = SteelDb::ingest_curated(corpus(), &curated, &wrong)
1390 .err()
1391 .expect("a surface list matching nothing must be refused");
1392 assert!(format!("{err}").contains("different corpora"), "{err}");
1393
1394 let right: Vec<String> =
1396 crate::emergent::mine_gazetteer(&corpus(), 2).into_iter().collect();
1397 if !right.is_empty() {
1398 let db = SteelDb::ingest_curated(corpus(), &curated, &right).expect("matching surfaces");
1399 assert_eq!(db.len(), corpus().len());
1400 }
1401
1402 SteelDb::ingest_curated(corpus(), &curated, &[]).expect("an empty gazetteer is legitimate");
1404 }
1405}