Skip to main content

steeldb/projectors/
text_proj.rs

1//! Native text projection — raw text → queryable Situation stream, entirely in Rust.
2//! `TextEngine` loads the ONNX models once (expensive) and can project many files/sentences;
3//! `TextProjector` wraps an engine + a single file for the `Projector` trait. Folder ingest loads
4//! one `TextEngine` and reuses it across every text file.
5
6use crate::projector::{slug, CorpusKind, Projector, Situation};
7use crate::text::{Gazetteer, SpladeProjector, SpoTagger};
8use std::path::{Path, PathBuf};
9
10type Res<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
11
12/// Non-discriminative facet leaves — they appear in most documents, so they add noise not signal to
13/// the bitmap programs (they co-occur with everything and swamp rank/crosstab/cooccurs). The gazetteer
14/// + whole-word tiers carry the real signal. Dropped at ingest. Ported from `pipeline.ts` SPLADE_NOISE.
15const SPLADE_NOISE: &[&str] = &[
16    "amazon", "aws", "cloud", "architecture", "pattern", "patterns", "management", "data", "time",
17    "work", "works", "operations", "service", "services", "enterprise", "application", "applications",
18    "production", "configuration", "solution", "solutions", "customer", "customers", "use", "uses",
19    "using", "system", "systems", "platform", "platforms", "capability", "capabilities", "feature",
20    "features", "support", "level", "provide", "provides", "available", "new", "business", "team",
21    "teams", "organization", "organizations", "user", "users",
22];
23
24fn leaf_of(t: &str) -> &str {
25    match t.find('/') {
26        Some(i) => &t[i + 1..],
27        None => t,
28    }
29}
30
31/// Loaded text-projection models (SPO tagger + optional English SPLADE + optional gazetteer),
32/// reusable across files.
33pub struct TextEngine {
34    tagger: SpoTagger,
35    splade: Option<SpladeProjector>,
36    gaz: Option<Gazetteer>,
37    /// When set, multi-word entity spans discovered at ingest are learned into the gazetteer overlay and
38    /// persisted here — the growing gazetteer. Later runs (ingest + query) recognise these entities.
39    overlay_path: Option<PathBuf>,
40    /// The **tuned** multi-head tagger (step 2). When present it supersedes the ONNX SPO tagger for
41    /// dimensions 1/3/4/5: it emits *typed* hierarchical entity URIs (`org/…`) and the epistemic
42    /// `state/…` cue that carries infon polarity, which the untyped ONNX tagger cannot. SPLADE (dim 6)
43    /// and the gazetteer still contribute either way.
44    #[cfg(feature = "native")]
45    tuned: Option<crate::tagger_train::TunedTagger>,
46}
47
48impl TextEngine {
49    pub fn load(ml_bundle: &Path, splade_dir: Option<&Path>) -> Res<TextEngine> {
50        let tagger = SpoTagger::load(ml_bundle)?;
51        let splade = match splade_dir {
52            Some(d) => Some(SpladeProjector::load(d, false)?),
53            None => None,
54        };
55        // High-res gazetteer: `STEELDB_GAZETTEER`, else `<splade_dir>/gazetteer.json` if present. Loaded
56        // once and matched per sentence so index and query share the same whole-entity token space.
57        let gaz = gazetteer_path(splade_dir).and_then(|p| Gazetteer::load(&p).ok());
58        Ok(TextEngine {
59            tagger,
60            splade,
61            gaz,
62            overlay_path: None,
63            #[cfg(feature = "native")]
64            tuned: None,
65        })
66    }
67
68    /// Enable the growing gazetteer: merge any prior overlay at `path`, then learn newly-discovered
69    /// multi-word entities during ingest and persist them back with [`Self::save_overlay`].
70    pub fn enable_growth(&mut self, path: impl Into<PathBuf>) {
71        let path = path.into();
72        let mut gaz = self.gaz.take().unwrap_or_else(Gazetteer::empty);
73        gaz.merge_overlay(&path);
74        self.gaz = Some(gaz);
75        self.overlay_path = Some(path);
76    }
77
78    /// Persist the learned overlay (no-op unless growth was enabled). Returns the learned-entry count.
79    pub fn save_overlay(&self) -> usize {
80        if let (Some(gaz), Some(path)) = (self.gaz.as_ref(), self.overlay_path.as_ref()) {
81            let _ = gaz.save_overlay(path);
82            gaz.learned_len()
83        } else {
84            0
85        }
86    }
87
88    /// Attach a tuned tagger (a `save()` directory from step 2) so ingest emits typed facets + polarity.
89    /// `base_dir` is the encoder's HF snapshot (config.json) and `tokenizer` its `tokenizer.json`.
90    #[cfg(feature = "native")]
91    pub fn enable_tuned(&mut self, dir: &Path, base_dir: &Path, tokenizer: &Path, max_len: usize) -> Res<()> {
92        self.tuned = Some(crate::tagger_train::TunedTagger::load(dir, base_dir, tokenizer, max_len)?);
93        Ok(())
94    }
95
96    /// Attach Head C (step 5) to the tuned tagger so ingest emits bound relation tokens.
97    #[cfg(feature = "native")]
98    pub fn enable_relations(&mut self, dir: &Path, spec: &crate::vocabulary::VocabularySpace) -> Res<()> {
99        match self.tuned.as_mut() {
100            Some(t) => {
101                t.enable_relations(dir, spec)?;
102                Ok(())
103            }
104            None => Err("no tuned tagger — call enable_tuned first".into()),
105        }
106    }
107
108    /// True when a tuned tagger is driving the entity/epistemic tiers.
109    pub fn is_tuned(&self) -> bool {
110        #[cfg(feature = "native")]
111        {
112            return self.tuned.is_some();
113        }
114        #[allow(unreachable_code)]
115        false
116    }
117
118    /// Project one sentence into a full [`Situation`] — tokens, numeric fields, **and infon polarity**.
119    /// This is the ingest entry point that preserves everything the tuned tagger knows; `project_sentence`
120    /// remains for callers that only need tokens.
121    pub fn project_situation(&mut self, sentence: &str) -> Situation {
122        #[cfg(feature = "native")]
123        if let Some(tt) = self.tuned.as_ref() {
124            if let Ok(mut sit) = tt.project(sentence) {
125                // dim 6 (latent motifs) and the gazetteer tier are orthogonal to the tuned tagger
126                if let Some(splade) = self.splade.as_mut() {
127                    if let Ok(terms) = splade.project(sentence, 8, 0.3) {
128                        for t in terms {
129                            if !SPLADE_NOISE.contains(&leaf_of(&t.token)) {
130                                sit.tokens.push(t.token);
131                            }
132                        }
133                    }
134                }
135                if let Some(gaz) = self.gaz.as_ref() {
136                    for h in gaz.extract(sentence) {
137                        sit.tokens.push(h.token);
138                    }
139                }
140                sit.tokens.sort();
141                sit.tokens.dedup();
142                // polarity applies to every token of a negated/hedged clause, including the tiers above
143                if let Some((_, level)) = sit.beliefs.first().map(|(k, v)| (k.clone(), *v)) {
144                    sit.beliefs = sit.tokens.iter().map(|t| (t.clone(), level)).collect();
145                }
146                return sit;
147            }
148        }
149        let (tokens, numbers) = self.project_sentence(sentence);
150        Situation { tokens, display: vec![sentence.to_string()], numbers, beliefs: Vec::new() }
151    }
152
153    /// Project one sentence → (sorted de-duplicated tokens, numeric quantity fields). QTY spans that
154    /// canonicalise (via `units`) become `(field, si_value)` pairs for `(num …)` range queries.
155    pub fn project_sentence(&mut self, sentence: &str) -> (Vec<String>, Vec<(String, f64)>) {
156        let mut tokens: Vec<String> = Vec::new();
157        let mut numbers: Vec<(String, f64)> = Vec::new();
158        if let Ok(spans) = self.tagger.tag(sentence) {
159            for s in spans {
160                let v = slug(&s.text);
161                if !v.is_empty() {
162                    let facet = facet_for(&s.kind);
163                    let raw = format!("{facet}/{v}");
164                    // Growing gazetteer + entity registration: for multi-word entity/place spans, register
165                    // a CANONICAL token (content-word core, determiner/role variants collapsed) so the same
166                    // entity's postings merge across surface variants, and future ingest + query recognise
167                    // it. Emit the canonical token in place of the drifting raw span. Single words are
168                    // already well-covered by the tagger/SPLADE tiers.
169                    let mut token = raw;
170                    if self.overlay_path.is_some() && matches!(s.kind.as_str(), "ENT" | "GEO") && s.text.split_whitespace().count() >= 2 {
171                        if let Some(gaz) = self.gaz.as_mut() {
172                            if let Some(canon) = gaz.register(&s.text, &facet) {
173                                token = canon;
174                            }
175                        }
176                    }
177                    tokens.push(token);
178                }
179                if s.kind == "QTY" {
180                    if let Some(nf) = crate::units::parse_quantity(&s.text) {
181                        numbers.push(nf);
182                    }
183                }
184            }
185        }
186        if let Some(splade) = self.splade.as_mut() {
187            if let Ok(terms) = splade.project(sentence, 8, 0.3) {
188                for t in terms {
189                    // NOISE REGISTRATION: drop the ultra-generic, non-discriminative facet leaves.
190                    if SPLADE_NOISE.contains(&leaf_of(&t.token)) {
191                        continue;
192                    }
193                    tokens.push(t.token);
194                }
195            }
196        }
197        // GAZETTEER tier: high-resolution whole-entity `facet/value` tokens (aws-service/amazon-eks,
198        // capability/zero-etl) — deterministic, span-grounded, the discriminative tokens the analytics
199        // programs reason over.
200        if let Some(gaz) = self.gaz.as_ref() {
201            for h in gaz.extract(sentence) {
202                tokens.push(h.token);
203            }
204        }
205        tokens.sort();
206        tokens.dedup();
207        (tokens, numbers)
208    }
209
210    /// Project one sentence to a sorted, de-duplicated token set (tokens only).
211    pub fn tokens_for(&mut self, sentence: &str) -> Vec<String> {
212        self.project_sentence(sentence).0
213    }
214
215    /// Drive a sink with one Situation per sentence of `text`.
216    pub fn project_text(&mut self, text: &str, sink: &mut dyn FnMut(Situation)) {
217        for sent in sentences(text) {
218            sink(self.project_situation(sent));
219        }
220    }
221}
222
223pub struct TextProjector {
224    path: PathBuf,
225    engine: TextEngine,
226}
227
228impl TextProjector {
229    pub fn open(path: impl Into<PathBuf>, ml_bundle: &Path, splade_dir: Option<&Path>) -> Res<TextProjector> {
230        Ok(TextProjector { path: path.into(), engine: TextEngine::load(ml_bundle, splade_dir)? })
231    }
232}
233
234/// Split into sentences on Latin + CJK terminators and newlines.
235pub fn sentences(text: &str) -> Vec<&str> {
236    let mut out = Vec::new();
237    let mut start = 0;
238    for (i, ch) in text.char_indices() {
239        if matches!(ch, '.' | '!' | '?' | '。' | '!' | '?' | '\n') {
240            let end = i + ch.len_utf8();
241            let s = text[start..end].trim();
242            if s.len() >= 2 {
243                out.push(s);
244            }
245            start = end;
246        }
247    }
248    let tail = text[start..].trim();
249    if tail.len() >= 2 {
250        out.push(tail);
251    }
252    out
253}
254
255/// Resolve the gazetteer artifact: `STEELDB_GAZETTEER` env override, else `<splade_dir>/gazetteer.json`.
256fn gazetteer_path(splade_dir: Option<&Path>) -> Option<PathBuf> {
257    if let Ok(p) = std::env::var("STEELDB_GAZETTEER") {
258        let p = PathBuf::from(p);
259        if p.exists() {
260            return Some(p);
261        }
262    }
263    splade_dir.map(|d| d.join("gazetteer.json")).filter(|p| p.exists())
264}
265
266/// tagger span kind → token facet prefix
267/// Map a tagger span kind to its infon facet prefix. The base tagger emits `ENT/REL/GEO/TIME/QTY`; a
268/// domain-adapted tagger emits **typed** kinds (`ORG`, `ARTIFACT`, `PLATFORM`, `GENE`, …) — those pass
269/// through as their own prefix (lowercased) so `org/toyota`, `artifact/battery_cell` etc. work directly
270/// (Vocabulary-Space dim 1). Typed relations carry polarity as a suffix kind (`REL+`/`REL-` → `rel/…/+`).
271fn facet_for(kind: &str) -> String {
272    match kind {
273        "ENT" => "ent".to_string(),
274        "REL" => "rel".to_string(),
275        "GEO" => "geo".to_string(),
276        "TIME" => "time".to_string(),
277        "QTY" => "qty".to_string(),
278        "" | "IGNORE" | "O" => "misc".to_string(),
279        // typed kind from a domain-adapted tagger → prefix is the kind itself, lowercased + slugged.
280        other => other.chars().filter(|c| c.is_alphanumeric() || *c == '-').flat_map(|c| c.to_lowercase()).collect(),
281    }
282}
283
284impl Projector for TextProjector {
285    fn columns(&self) -> Vec<String> {
286        vec!["sentence".to_string()]
287    }
288    fn kind(&self) -> CorpusKind {
289        CorpusKind::Text
290    }
291    fn source(&self) -> String {
292        self.path.display().to_string()
293    }
294    fn project(mut self: Box<Self>, sink: &mut dyn FnMut(Situation)) -> std::io::Result<()> {
295        let text = std::fs::read_to_string(&self.path)?;
296        self.engine.project_text(&text, sink);
297        Ok(())
298    }
299}