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 let registrations: Vec<crate::artifact::Registration> = surfaces
429 .iter()
430 .map(|surface| crate::artifact::Registration {
431 surface: surface.clone(),
432 token: format!("entity/{}", crate::projector::slug(surface)),
433 })
434 .collect();
435 let corpus = Self::project_with(&documents, &categories, ®istrations, &motifs);
436 Ok(SteelDb { corpus, categories, motifs, registrations, documents, min_gain })
437 }
438
439 pub fn ingest_using<I, S>(docs: I, artifact_dir: impl AsRef<Path>) -> Result<Self, Error>
446 where
447 I: IntoIterator<Item = S>,
448 S: AsRef<str>,
449 {
450 let set = crate::artifact::Artifacts::load(artifact_dir).map_err(Error::Artifact)?;
451 let documents: Vec<String> =
452 docs.into_iter().map(|d| d.as_ref().trim().to_string()).filter(|d| !d.is_empty()).collect();
453 if documents.is_empty() {
454 return Err(Error::Empty("no non-empty documents".into()));
455 }
456 let categories: Vec<(String, Vec<String>)> =
457 set.categories.into_iter().map(|c| (c.name, c.words)).collect();
458 let motifs: Vec<(String, Vec<String>)> =
461 set.motifs.into_iter().map(|m| (m.name, m.words)).collect();
462 let corpus = Self::project_with(&documents, &categories, &set.gazetteer, &motifs);
463 Ok(SteelDb {
464 corpus,
465 categories,
466 motifs,
467 registrations: set.gazetteer,
468 documents,
469 min_gain: Options::default().min_gain,
470 })
471 }
472
473 pub fn save(&self, artifact_dir: impl AsRef<Path>) -> Result<(), Error> {
478 let categories = self
479 .categories
480 .iter()
481 .map(|(name, words)| crate::artifact::CategoryRecord {
482 name: name.clone(),
483 words: words.clone(),
484 })
485 .collect();
486 let registrations = self.registrations.clone();
489 let surfaces: Vec<String> = registrations.iter().map(|r| r.surface.clone()).collect();
490 let matcher = crate::emergent::MentionMatcher::new(&surfaces);
491 let mut relations: Vec<String> = Vec::new();
492 for doc in &self.documents {
493 for r in crate::emergent::relation_spans_with(doc, &matcher) {
494 if !relations.contains(&r.verb) {
495 relations.push(r.verb);
496 }
497 }
498 }
499 let motifs = self
500 .motifs
501 .iter()
502 .map(|(name, words)| crate::artifact::CategoryRecord {
503 name: name.clone(),
504 words: words.clone(),
505 })
506 .collect();
507 crate::artifact::Artifacts::new("discovery", categories, registrations, relations)
508 .with_motifs(motifs)
509 .save(artifact_dir)
510 .map_err(Error::Artifact)
511 }
512
513 pub fn save_with_training(&self, artifact_dir: impl AsRef<Path>) -> Result<usize, Error> {
524 let gazetteer: Vec<String> = self.registrations.iter().map(|r| r.surface.clone()).collect();
526 let mut jsonl = String::new();
527 for doc in &self.documents {
528 let mut spans: Vec<serde_json::Value> = Vec::new();
529 let mut push = |s: usize, e: usize, facet: &str| {
530 if let Some(surface) = doc.get(s..e) {
531 spans.push(serde_json::json!({
532 "start": s, "end": e, "facet": facet, "surface": surface,
533 }));
534 }
535 };
536 for m in &gazetteer {
537 for (s, e) in crate::emergent::word_spans(doc, m) {
538 push(s, e, "entity");
539 }
540 }
541 for (s, e, field) in crate::emergent::quantity_spans(doc) {
542 push(s, e, &format!("qty/{field}"));
543 }
544 for (s, e, tok) in crate::emergent::temporal_spans(doc) {
545 let _ = tok;
546 push(s, e, "time");
547 }
548 for (cat, words) in &self.categories {
549 for w in words {
550 for (s, e) in crate::emergent::word_spans(doc, w) {
551 push(s, e, cat);
552 }
553 }
554 }
555 spans.sort_by_key(|v| (v["start"].as_u64().unwrap_or(0), v["end"].as_u64().unwrap_or(0)));
557 let mut kept: Vec<serde_json::Value> = Vec::new();
558 let mut cursor = 0u64;
559 for sp in spans {
560 let (s, e) = (sp["start"].as_u64().unwrap_or(0), sp["end"].as_u64().unwrap_or(0));
561 if s >= cursor {
562 cursor = e;
563 kept.push(sp);
564 }
565 }
566 if kept.is_empty() {
567 continue; }
569 let line = serde_json::json!({ "text": doc, "spans": kept });
570 jsonl.push_str(&line.to_string());
571 jsonl.push('\n');
572 }
573
574 let categories = self
575 .categories
576 .iter()
577 .map(|(name, words)| crate::artifact::CategoryRecord {
578 name: name.clone(),
579 words: words.clone(),
580 })
581 .collect();
582 let mut relations: Vec<String> = Vec::new();
583 for doc in &self.documents {
584 for r in crate::emergent::relation_spans(doc, &gazetteer) {
585 if !relations.contains(&r.verb) {
586 relations.push(r.verb);
587 }
588 }
589 }
590 let set = crate::artifact::Artifacts::new(
591 "discovery",
592 categories,
593 self.registrations.clone(),
594 relations,
595 )
596 .with_motifs(
597 self.motifs
598 .iter()
599 .map(|(name, words)| crate::artifact::CategoryRecord {
600 name: name.clone(),
601 words: words.clone(),
602 })
603 .collect(),
604 )
605 .with_training(jsonl);
606 let n = set.training_examples;
607 set.save(artifact_dir).map_err(Error::Artifact)?;
608 Ok(n)
609 }
610
611 pub fn open(dir: impl AsRef<Path>) -> Result<Self, Error> {
613 let dir = dir.as_ref();
614 let mut docs: Vec<String> = Vec::new();
615 for entry in std::fs::read_dir(dir)? {
616 let path = entry?.path();
617 if path.extension().map(|e| e == "md" || e == "txt").unwrap_or(false) {
618 if let Ok(text) = std::fs::read_to_string(&path) {
619 docs.push(text);
620 }
621 }
622 }
623 if docs.is_empty() {
624 return Err(Error::Empty(format!("no .md or .txt files under {}", dir.display())));
625 }
626 Self::ingest(docs)
627 }
628
629 fn registrations(docs: &[String]) -> Vec<crate::artifact::Registration> {
631 crate::emergent::mine_gazetteer(docs, 2)
632 .into_iter()
633 .map(|surface| {
634 let token = format!("entity/{}", crate::projector::slug(&surface));
635 crate::artifact::Registration { surface, token }
636 })
637 .collect()
638 }
639
640 fn project_with(
643 docs: &[String],
644 categories: &[(String, Vec<String>)],
645 registrations: &[crate::artifact::Registration],
646 motifs: &[(String, Vec<String>)],
647 ) -> Corpus {
648 let gazetteer: Vec<String> = registrations.iter().map(|r| r.surface.clone()).collect();
649 let canonical: std::collections::HashMap<&str, &str> =
651 registrations.iter().map(|r| (r.surface.as_str(), r.token.as_str())).collect();
652 let matcher = crate::emergent::MentionMatcher::new(&gazetteer);
655
656 let projected: Vec<Projected> = Self::project_docs_parallel(docs, &matcher, &canonical, categories, motifs);
663
664 let mut corpus = Corpus::new_incremental("documents", vec!["document".into()], CorpusKind::Text);
665 for p in projected {
666 corpus.add_situation_polar(p.tags, p.display, p.numbers, p.beliefs);
667 }
668 corpus
669 }
670
671 fn project_docs_parallel(
674 docs: &[String],
675 matcher: &crate::emergent::MentionMatcher,
676 canonical: &std::collections::HashMap<&str, &str>,
677 categories: &[(String, Vec<String>)],
678 motifs: &[(String, Vec<String>)],
679 ) -> Vec<Projected> {
680 #[cfg(not(target_arch = "wasm32"))]
681 {
682 let workers = std::thread::available_parallelism()
684 .map(|n| n.get())
685 .unwrap_or(6)
686 .max(6)
687 .min(docs.len().max(1));
688 if workers > 1 && docs.len() > 1 {
689 let chunk = docs.len().div_ceil(workers);
690 let mut chunks: Vec<Vec<Projected>> = Vec::new();
691 std::thread::scope(|scope| {
692 let handles: Vec<_> = docs
693 .chunks(chunk)
694 .map(|slice| {
695 scope.spawn(move || {
696 slice
697 .iter()
698 .map(|d| Self::project_doc(d, matcher, canonical, categories, motifs))
699 .collect::<Vec<_>>()
700 })
701 })
702 .collect();
703 for h in handles {
705 chunks.push(h.join().expect("projection worker panicked"));
706 }
707 });
708 return chunks.into_iter().flatten().collect();
709 }
710 }
711 docs.iter().map(|d| Self::project_doc(d, matcher, canonical, categories, motifs)).collect()
712 }
713
714 fn project_doc(
717 doc: &str,
718 matcher: &crate::emergent::MentionMatcher,
719 canonical: &std::collections::HashMap<&str, &str>,
720 categories: &[(String, Vec<String>)],
721 motifs: &[(String, Vec<String>)],
722 ) -> Projected {
723 let mut tags: Vec<String> = Vec::new();
724 let mut numbers: Vec<(String, f64)> = Vec::new();
725 let lower = doc.to_lowercase();
729 let level = crate::dimensions::belief_level(
730 lower.contains("not permitted") || lower.contains("is not ") || lower.contains("no longer"),
731 lower.contains("under review") || lower.contains("may be") || lower.contains("provisional"),
732 );
733
734 for mention in matcher.present(doc) {
735 match canonical.get(mention.as_str()) {
737 Some(tok) => tags.push((*tok).to_string()),
738 None => tags.push(format!("entity/{}", crate::projector::slug(mention))),
739 }
740 }
741 for r in crate::emergent::relation_spans_with(doc, matcher) {
742 let verb = crate::projector::slug(&r.verb);
743 tags.push(format!("rel/{verb}/+"));
748 tags.push(format!("rel/{verb}/+/{}", crate::projector::slug(&r.actor)));
749 tags.push(format!("rel/{verb}/-"));
750 tags.push(format!("rel/{verb}/-/{}", crate::projector::slug(&r.target)));
751 }
752 for (_, _, tok) in crate::emergent::temporal_spans(doc) {
753 tags.push(tok);
754 }
755 for (st, en, field) in crate::emergent::quantity_spans(doc) {
756 tags.push(format!("quantity/{field}"));
757 let digits: String = doc[st..en]
758 .chars()
759 .enumerate()
760 .take_while(|(i, c)| c.is_ascii_digit() || *c == '.' || (*i == 0 && *c == '-'))
761 .map(|(_, c)| c)
762 .collect();
763 if let Ok(v) = digits.parse::<f64>() {
764 numbers.push((field, v));
765 }
766 }
767 for (cat, terms) in categories {
768 for t in terms {
769 if crate::emergent::contains_term(doc, t) {
770 tags.push(format!("{cat}/{}", crate::projector::slug(t)));
771 }
772 }
773 }
774 for (name, terms) in motifs {
777 if terms.iter().any(|t| crate::emergent::contains_term(doc, t)) {
778 tags.push(format!("motif/{}", crate::projector::slug(name)));
779 }
780 }
781 tags.push(
782 match level {
783 l if l < 0.0 => "state/negated",
784 l if l < 1.0 => "state/hedged",
785 _ => "state/asserted",
786 }
787 .to_string(),
788 );
789
790 let beliefs: Vec<(String, f32)> = tags.iter().map(|t| (t.clone(), level)).collect();
791 let display = vec![doc.chars().take(160).collect::<String>()];
792 numbers.dedup_by(|a, b| a.0 == b.0);
793 Projected { tags, display, numbers, beliefs }
794 }
795
796 pub fn query(&self, ikl: &str) -> Result<Answer, Refused> {
801 let report = self.corpus.linter().lint(ikl);
802 if let Some(fixed) = &report.repaired {
803 return Err(Refused {
808 query: ikl.to_string(),
809 problems: vec![if fixed.trim().is_empty() {
810 "unbalanced parentheses: a ')' with no matching '(' leaves nothing to run".into()
811 } else {
812 format!("unbalanced parentheses; did you mean: {fixed}")
813 }],
814 alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
815 });
816 }
817 if !report.ok {
818 return Err(Refused {
819 query: ikl.to_string(),
820 problems: report.errors.iter().map(|e| e.message.clone()).collect(),
821 alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
822 });
823 }
824 match crate::tokenql::try_evaluate(self.corpus.index(), ikl) {
825 Ok(set) => Ok(Answer { ids: set.to_sorted(), micros: 0.0 }),
826 Err(e) => Err(Refused {
827 query: ikl.to_string(),
828 problems: vec![e.to_string()],
829 alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
830 }),
831 }
832 }
833
834 pub fn check(&self, ikl: &str) -> Result<(), Refused> {
836 let report = self.corpus.linter().lint(ikl);
837 if let Some(fixed) = &report.repaired {
838 return Err(Refused {
843 query: ikl.to_string(),
844 problems: vec![if fixed.trim().is_empty() {
845 "unbalanced parentheses: a ')' with no matching '(' leaves nothing to run".into()
846 } else {
847 format!("unbalanced parentheses; did you mean: {fixed}")
848 }],
849 alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
850 });
851 }
852 if report.ok {
853 Ok(())
854 } else {
855 Err(Refused {
856 query: ikl.to_string(),
857 problems: report.errors.iter().map(|e| e.message.clone()).collect(),
858 alternatives: self.categories.iter().map(|(c, _)| format!("{c}/*")).collect(),
859 })
860 }
861 }
862
863 pub fn belief(&self, tag: &str) -> Interval {
865 let (belief, plausibility) = self.corpus.belief_interval(tag);
866 Interval { belief, plausibility }
867 }
868
869 pub fn s_path(&self, from: &str, to: &str, s: usize) -> Answer {
873 let set = self.corpus.index().s_path_tokens(from, to, s).map(|chain| {
874 let mut out = P::empty();
875 for tok in &chain {
876 out.or_inplace(&crate::tokenql::TokenStore::atom(self.corpus.index(), tok));
877 }
878 out
879 });
880 Answer { ids: set.map(|s| s.to_sorted()).unwrap_or_default(), micros: 0.0 }
881 }
882
883 pub fn filtration(&self, max_s: usize) -> Vec<crate::programs::Level> {
885 crate::programs::s_filtration(self.corpus.index(), max_s, &Default::default(), 128)
886 }
887
888 pub fn categories(&self) -> Vec<Category<'_>> {
890 self.categories.iter().map(|(name, words)| Category { name, words }).collect()
891 }
892
893 pub fn askable(&self) -> Vec<String> {
895 self.categories.iter().map(|(c, _)| format!("{c}/*")).collect()
896 }
897
898 pub fn tags(&self) -> BTreeMap<String, Vec<String>> {
903 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
904 for tag in self.corpus.index().tokens() {
905 let stem = tag.split('/').next().unwrap_or("").to_string();
906 out.entry(stem).or_default().push(tag.clone());
907 }
908 for v in out.values_mut() {
909 v.sort();
910 v.dedup();
911 }
912 out
913 }
914
915 pub fn text(&self, situation: u32) -> Option<&str> {
920 self.documents.get(situation as usize).map(|s| s.as_str())
921 }
922
923 pub fn resolve<'a>(&'a self, answer: &'a Answer) -> impl Iterator<Item = (u32, &'a str)> + 'a {
925 answer.ids().iter().filter_map(move |id| self.text(*id).map(|t| (*id, t)))
926 }
927
928 pub fn len(&self) -> usize {
930 self.documents.len()
931 }
932 pub fn is_empty(&self) -> bool {
933 self.documents.is_empty()
934 }
935 pub fn documents(&self) -> &[String] {
937 &self.documents
938 }
939
940 pub(crate) fn spec_snapshot(&self) -> crate::vocabulary::VocabularySpace {
944 crate::vocabulary::VocabularySpace {
945 version: 1,
946 corpus: "documents".into(),
947 entity_facets: self
948 .categories
949 .iter()
950 .map(|(name, words)| crate::vocabulary::EntityFacet {
951 name: name.clone(),
952 parent: None,
953 description: String::new(),
954 examples: words.clone(),
955 structural: false,
956 })
957 .collect(),
958 relation_facets: Vec::new(),
959 gazetteer: Vec::new(),
960 metrics: None,
961 }
962 }
963
964 #[cfg(feature = "wasm")]
967 pub(crate) fn into_corpus(self) -> Corpus {
968 self.corpus
969 }
970
971 pub(crate) fn min_gain(&self) -> f64 {
972 self.min_gain
973 }
974
975 pub(crate) fn push_category(&mut self, name: String, words: Vec<String>) {
976 self.categories.push((name, words));
977 }
978
979 pub(crate) fn reproject(&mut self) {
982 self.corpus = Self::project_with(&self.documents, &self.categories, &self.registrations, &self.motifs);
985 }
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991
992 fn corpus() -> Vec<String> {
993 [
994 "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational in 2025.",
995 "Bea Strike defeated Iris Draco at Ecruteak City during the Indigo Invitational in 2025.",
996 "A habitat survey recorded Aggron near Sootopolis City at an elevation of 1082 m.",
997 "A habitat survey recorded Salamence near Sootopolis City at an elevation of 2369 m.",
998 "Milotic is not permitted in Series 1 play for the 2025 season.",
999 "Metagross is permitted in Series 4 play for the 2026 season.",
1000 ]
1001 .iter()
1002 .map(|s| s.to_string())
1003 .collect()
1004 }
1005
1006 #[test]
1007 fn three_lines_to_a_working_database() {
1008 let db = SteelDb::ingest(corpus()).expect("index");
1009 assert_eq!(db.len(), 6);
1010 assert!(!db.categories().is_empty(), "should discover at least one category");
1011 }
1012
1013 #[test]
1014 fn an_unsupported_query_is_refused_with_alternatives() {
1015 let db = SteelDb::ingest(corpus()).unwrap();
1016 let err = db.query("gene/brca1").expect_err("must refuse a category the data lacks");
1017 assert!(!err.alternatives.is_empty(), "a refusal must say what does exist");
1018 let shown = err.to_string();
1019 assert!(shown.contains("refused"), "{shown}");
1020 assert!(shown.contains("available"), "{shown}");
1021 }
1022
1023 #[test]
1024 fn a_supported_query_returns_a_complete_set() {
1025 let db = SteelDb::ingest(corpus()).unwrap();
1026 let cat = db.categories()[0].name.to_string();
1027 let answer = db.query(&format!("{cat}/*")).expect("a discovered category must be queryable");
1028 assert!(!answer.is_empty());
1029 assert!(answer.ids().iter().all(|id| (*id as usize) < db.len()));
1031 assert_eq!(answer.ids().len(), (&answer).into_iter().count());
1033 }
1034
1035 #[test]
1036 fn negation_narrows_rather_than_widens() {
1037 let db = SteelDb::ingest(corpus()).unwrap();
1038 let cat = db.categories()[0].name.to_string();
1039 let all = db.query(&format!("{cat}/*")).unwrap().len();
1040 let some = db.query(&format!("(and {cat}/* (not state/negated))")).unwrap().len();
1041 assert!(some <= all, "excluding something cannot return more: {some} vs {all}");
1042 }
1043
1044 #[test]
1045 fn belief_separates_asserted_from_negated() {
1046 let db = SteelDb::ingest(corpus()).unwrap();
1047 let asserted = db.belief("state/asserted");
1048 let negated = db.belief("state/negated");
1049 assert!(asserted.belief > negated.belief, "{asserted} vs {negated}");
1050 assert!(asserted.ignorance() >= 0.0);
1052 assert!(db.belief("state/nonexistent").is_unknown(), "an absent tag is unknown, not refuted");
1053 }
1054
1055 #[test]
1056 fn check_costs_nothing_and_agrees_with_query() {
1057 let db = SteelDb::ingest(corpus()).unwrap();
1058 assert!(db.check("gene/brca1").is_err());
1059 assert!(db.query("gene/brca1").is_err());
1060 let cat = db.categories()[0].name.to_string();
1061 assert!(db.check(&format!("{cat}/*")).is_ok());
1062 }
1063
1064 #[test]
1065 fn the_filtration_thins_as_the_threshold_rises() {
1066 let db = SteelDb::ingest(corpus()).unwrap();
1067 let levels = db.filtration(4);
1068 assert_eq!(levels.len(), 4);
1069 for w in levels.windows(2) {
1071 assert!(w[1].primal.edges <= w[0].primal.edges, "edges must not grow with s");
1072 assert!(w[1].dual.edges <= w[0].dual.edges);
1073 }
1074 }
1075
1076 #[test]
1077 fn empty_input_is_an_error_not_an_empty_database() {
1078 assert!(matches!(SteelDb::ingest(Vec::<String>::new()), Err(Error::Empty(_))));
1079 assert!(matches!(SteelDb::ingest(vec![" ", ""]), Err(Error::Empty(_))));
1080 }
1081
1082 #[test]
1083 fn artefacts_make_a_later_ingest_reproducible() {
1084 let dir = std::env::temp_dir().join(format!("hsdb_api_repro_{}", std::process::id()));
1087 let _ = std::fs::remove_dir_all(&dir);
1088
1089 let first = SteelDb::ingest(corpus()).unwrap();
1090 first.save(&dir).unwrap();
1091 let cat = first.categories()[0].name.to_string();
1092 let expected = first.query(&format!("{cat}/*")).unwrap().len();
1093
1094 let second = SteelDb::ingest_using(corpus(), &dir).unwrap();
1095 assert_eq!(
1096 second.categories().iter().map(|c| c.name.to_string()).collect::<Vec<_>>(),
1097 first.categories().iter().map(|c| c.name.to_string()).collect::<Vec<_>>(),
1098 "the recorded vocabulary must be reproduced exactly"
1099 );
1100 assert_eq!(second.query(&format!("{cat}/*")).unwrap().len(), expected, "and answer identically");
1101 let _ = std::fs::remove_dir_all(&dir);
1102 }
1103
1104 #[test]
1105 fn saved_artefacts_contain_no_document_text() {
1106 let dir = std::env::temp_dir().join(format!("hsdb_api_leak_{}", std::process::id()));
1108 let _ = std::fs::remove_dir_all(&dir);
1109 let db = SteelDb::ingest(corpus()).unwrap();
1110 db.save(&dir).unwrap();
1111
1112 for entry in std::fs::read_dir(&dir).unwrap() {
1113 let p = entry.unwrap().path();
1114 let text = std::fs::read_to_string(&p).unwrap();
1115 for doc in corpus() {
1116 assert!(
1117 !text.contains(doc.as_str()),
1118 "{} contains a whole document",
1119 p.display()
1120 );
1121 let frag: String = doc.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
1123 assert!(!text.contains(&frag), "{} contains the fragment {frag:?}", p.display());
1124 }
1125 }
1126 let _ = std::fs::remove_dir_all(&dir);
1127 }
1128
1129 #[test]
1130 fn ingesting_against_a_missing_artefact_set_is_an_error() {
1131 let missing = std::env::temp_dir().join("hsdb_definitely_absent_dir");
1132 let _ = std::fs::remove_dir_all(&missing);
1133 assert!(matches!(
1134 SteelDb::ingest_using(corpus(), &missing),
1135 Err(Error::Artifact(_))
1136 ));
1137 }
1138
1139 #[test]
1140 fn a_finetuning_set_is_written_separately_from_the_vocabulary() {
1141 let dir = std::env::temp_dir().join(format!("hsdb_api_train_{}", std::process::id()));
1142 let _ = std::fs::remove_dir_all(&dir);
1143 let db = SteelDb::ingest(corpus()).unwrap();
1144 let n = db.save_with_training(&dir).unwrap();
1145 assert!(n > 0, "the corpus should yield labelled passages");
1146
1147 for f in ["manifest.json", "vocabulary.json", "gazetteer.json", "relations.json"] {
1149 let text = std::fs::read_to_string(dir.join(f)).unwrap();
1150 for doc in corpus() {
1151 let frag: String = doc.split_whitespace().take(6).collect::<Vec<_>>().join(" ");
1152 assert!(!text.contains(&frag), "{f} leaked: {frag:?}");
1153 }
1154 }
1155 let train = std::fs::read_to_string(dir.join("training").join("spans.jsonl")).unwrap();
1157 assert!(train.contains("Morty Shade"), "the finetuning set needs the words");
1158 assert!(dir.join(".gitignore").exists(), "and must be excluded from commits");
1159 let _ = std::fs::remove_dir_all(&dir);
1160 }
1161
1162 #[test]
1163 fn training_spans_do_not_overlap() {
1164 let dir = std::env::temp_dir().join(format!("hsdb_api_ovl_{}", std::process::id()));
1166 let _ = std::fs::remove_dir_all(&dir);
1167 SteelDb::ingest(corpus()).unwrap().save_with_training(&dir).unwrap();
1168 let train = std::fs::read_to_string(dir.join("training").join("spans.jsonl")).unwrap();
1169 for line in train.lines().filter(|l| !l.trim().is_empty()) {
1170 let v: serde_json::Value = serde_json::from_str(line).unwrap();
1171 let spans = v["spans"].as_array().unwrap();
1172 let mut last_end = 0u64;
1173 for sp in spans {
1174 let s = sp["start"].as_u64().unwrap();
1175 let e = sp["end"].as_u64().unwrap();
1176 assert!(s >= last_end, "span {s}..{e} overlaps the previous one ending at {last_end}");
1177 assert!(e > s, "empty span");
1178 last_end = e;
1179 }
1180 }
1181 let _ = std::fs::remove_dir_all(&dir);
1182 }
1183
1184 #[test]
1185 fn registered_variants_still_merge_after_an_artefact_reload() {
1186 let dir = std::env::temp_dir().join(format!("hsdb_api_canon_{}", std::process::id()));
1190 let _ = std::fs::remove_dir_all(&dir);
1191
1192 let db = SteelDb::ingest(corpus()).unwrap();
1193 db.save(&dir).unwrap();
1194
1195 let set = crate::artifact::Artifacts::load(&dir).unwrap();
1196 assert!(!set.gazetteer.is_empty(), "the corpus should register some mentions");
1197 for r in &set.gazetteer {
1198 assert!(!r.token.is_empty(), "every registration needs a canonical token");
1199 assert!(r.token.contains('/'), "a token is facet-qualified: {}", r.token);
1200 }
1201
1202 let reloaded = SteelDb::ingest_using(corpus(), &dir).unwrap();
1204 let tags_before: Vec<String> =
1205 db.tags().get("entity").cloned().unwrap_or_default();
1206 let tags_after: Vec<String> =
1207 reloaded.tags().get("entity").cloned().unwrap_or_default();
1208 assert_eq!(tags_before, tags_after, "entity tags must survive the round trip unchanged");
1209 let _ = std::fs::remove_dir_all(&dir);
1210 }
1211
1212 #[test]
1213 fn parallel_projection_preserves_document_order() {
1214 let docs: Vec<String> = (0..40)
1218 .map(|i| format!("Trainer{i} Shade defeated Rival{i} Gale at Ecruteak City in 2025."))
1219 .collect();
1220 let db = SteelDb::ingest(docs.clone()).expect("ingest");
1221 assert_eq!(db.len(), 40);
1222 for (i, doc) in docs.iter().enumerate() {
1223 let shown = db.text(i as u32).expect("every situation resolves");
1224 let head: String = doc.chars().take(20).collect();
1225 assert!(shown.starts_with(&head), "situation {i} shows {shown:?}, not document {i} ({head:?})");
1226 }
1227 }
1228
1229 #[test]
1230 fn ingest_is_deterministic_under_parallelism() {
1231 let docs: Vec<String> = (0..40)
1234 .map(|i| {
1235 if i % 2 == 0 {
1236 format!("Trainer{i} defeated Rival{i} at Ecruteak City in 2025.")
1237 } else {
1238 format!("A survey recorded Aggron near Sootopolis City at {} m.", 600 + i)
1239 }
1240 })
1241 .collect();
1242 let a = SteelDb::ingest(docs.clone()).expect("a");
1243 let b = SteelDb::ingest(docs).expect("b");
1244 assert_eq!(a.tags(), b.tags(), "parallel ingest is not deterministic");
1245 assert_eq!(a.askable(), b.askable());
1246 }
1247
1248 #[test]
1249 fn an_artefact_reload_indexes_identically_to_discovery() {
1250 let docs = corpus();
1255 let db = SteelDb::ingest(docs.clone()).expect("ingest");
1256
1257 let dir = std::env::temp_dir().join(format!("steeldb-reload-{}", std::process::id()));
1258 let _ = std::fs::remove_dir_all(&dir);
1259 db.save(&dir).expect("save");
1260 let reloaded = SteelDb::ingest_using(docs, &dir).expect("reload");
1261
1262 assert_eq!(db.tags(), reloaded.tags(), "an artefact reload must index identically");
1263 assert_eq!(db.askable(), reloaded.askable());
1264 let _ = std::fs::remove_dir_all(&dir);
1265 }
1266
1267 #[test]
1268 fn a_relation_records_who_was_on_each_side() {
1269 let db = SteelDb::ingest(corpus()).expect("ingest");
1272 let rel = db.tags().get("rel").cloned().unwrap_or_default();
1273
1274 let bound: Vec<&String> = rel.iter().filter(|t| t.matches('/').count() == 3).collect();
1275 assert!(!bound.is_empty(), "no argument-bound relation tokens: {rel:?}");
1276 assert!(
1277 bound.iter().any(|t| t.contains("/+/") ) && bound.iter().any(|t| t.contains("/-/")),
1278 "both sides must be recorded: {bound:?}"
1279 );
1280 assert!(
1282 rel.iter().any(|t| t.ends_with("morty-shade")),
1283 "a name opening a sentence must not be truncated: {rel:?}"
1284 );
1285 }
1286
1287 #[test]
1288 fn a_refusal_stays_readable_rather_than_running_off_the_line() {
1289 let db = SteelDb::ingest(corpus()).unwrap();
1293 let shown = db.query("gene/brca1").unwrap_err().to_string();
1294
1295 for line in shown.lines() {
1296 assert!(line.chars().count() <= 78, "line is {} chars: {line:?}", line.chars().count());
1297 }
1298 assert!(shown.contains("defeated"), "{shown}");
1300 assert!(!shown.contains(",,") && !shown.contains(" ,"), "mangled list: {shown}");
1301 for line in shown.lines() {
1303 let t = line.trim_end();
1304 if t.ends_with("defeated") || t.ends_with("elevation") {
1305 panic!("a wrapped list line lost its comma: {shown}");
1306 }
1307 }
1308 }
1309
1310 #[test]
1311 fn wrapping_leaves_a_short_message_untouched() {
1312 assert_eq!(wrap_indented("a, b", 70, " "), "a, b");
1313 assert_eq!(wrap_indented("", 70, " "), "");
1314 assert_eq!(wrap_indented("single", 2, " "), "single", "one oversized item cannot be split");
1315 }
1316
1317 #[test]
1318 fn unbalanced_parentheses_are_refused_rather_than_repaired_or_crashed() {
1319 let db = SteelDb::ingest(corpus()).unwrap();
1327
1328 for q in [")", "(", "(and", "))))", "((((", "(and a b", "(or (not x"] {
1329 let e = db
1330 .query(q)
1331 .err()
1332 .unwrap_or_else(|| panic!("{q:?} was answered instead of refused"));
1333 assert!(
1334 e.problems.iter().any(|p| p.contains("unbalanced")),
1335 "{q:?} refused for the wrong reason: {:?}",
1336 e.problems
1337 );
1338 assert!(db.check(q).is_err(), "check accepted {q:?} while query refused it");
1340 }
1341
1342 assert!(db.query("state/asserted").is_ok());
1344 assert!(db.query("(not state/negated)").is_ok());
1345 }
1346
1347 #[test]
1348 fn the_parser_never_panics_on_hostile_input() {
1349 for q in [")", "((", "()", "\"", "\"unclosed", "(\")\")", "\0", "(((((((((((((((((((("] {
1351 let _ = crate::tokenql::parse(q);
1352 }
1353 }
1354}