steeldb/tagger_discover.rs
1//! **Stage 1-2 of vocabulary discovery: the tagger and the codebook.**
2//!
3//! This produces a **raw spec**, not an ontology, and the distinction is the whole design:
4//!
5//! 1. the SPO tagger reads each sentence and marks typed spans, each arriving already labelled by kind;
6//! 2. every span becomes a 128-dimensional point via the model2vec embedder;
7//! 3. **within each kind separately**, k-means proposes prototypes and Sinkhorn optimal transport assigns spans
8//! to them, each cluster named by its most frequent surface term.
9//!
10//! What comes out is a list of raw **entity-value clusters** — and a cluster label is a *value*, not a facet.
11//! The reference's own committed spec makes this plain: its raw clusters are `missile`, `bo`, `guidance
12//! control`, `ph`, `ge`, and only after curation does the ontology read `weapon-platform`, `control-system`,
13//! with those raw terms demoted to examples. Treating a cluster label as a facet name yields exactly that
14//! junk — `ph` and `ge` as retrieval dimensions.
15//!
16//! So this module deliberately stops short of naming facets. Turning raw clusters into canonical MECE facet
17//! *types* — merging synonyms, dropping boilerplate, naming the kind — is judgment, and it belongs to
18//! [`crate::learn`]'s curation step.
19//!
20//! The six dimensions of V are handled by kind, as the reference does:
21//!
22//! | kind | treatment |
23//! |---|---|
24//! | `ENT`, `GEO` | clustered into raw entity-value clusters, for curation |
25//! | `REL` | clustered into raw relation clusters, for curation |
26//! | `TIME` | not clustered — the deterministic temporal extractor buckets these exactly |
27//! | `QTY` | routed OUT of the codebook: a measurement is a value on a scale, not a kind of thing |
28//! | `IGNORE` | dropped by the tagger |
29//!
30//! Native-only: the tagger is ONNX and the embedder's tokenizer links a C library, so neither targets wasm32.
31
32use crate::discover_ontology::OtDiscover;
33use crate::text::{tagger::SpoTagger, Model2Vec};
34
35/// The kinds that form entity-value clusters. `REL` is clustered separately; `QTY`/`TIME` never enter a
36/// codebook.
37const ENTITY_KINDS: &[&str] = &["ENT", "GEO"];
38
39/// One raw cluster: a label drawn from its most representative member, and the members themselves.
40///
41/// The label is a *value*, not a facet name. It exists to give the curator something to refer to.
42#[derive(Debug, Clone)]
43pub struct RawCluster {
44 /// the most representative member, used only as a handle for curation
45 pub label: String,
46 /// the cluster's members, most representative first — the surface forms
47 pub terms: Vec<String>,
48}
49
50/// What the tagger and the codebook produce: candidate clusters awaiting curation.
51#[derive(Debug, Clone, Default)]
52pub struct RawSpec {
53 /// entity-value clusters from the `ENT` and `GEO` kinds
54 pub entity_clusters: Vec<RawCluster>,
55 /// relation clusters from the `REL` kind
56 pub relation_clusters: Vec<RawCluster>,
57 /// how many documents were read
58 pub documents: usize,
59 /// spans routed away from the codebook, by kind — reported so a caller can see the routing happened
60 pub routed_away: Vec<(String, usize)>,
61}
62
63impl RawSpec {
64 /// Every entity surface form seen, longest first so gazetteer matching prefers the full mention.
65 ///
66 /// These are the *observed* spans, independent of how curation groups them, so the gazetteer survives
67 /// whatever the curator decides to merge or drop.
68 pub fn surfaces(&self) -> Vec<String> {
69 let mut out: Vec<String> = Vec::new();
70 for c in &self.entity_clusters {
71 for t in &c.terms {
72 if !out.contains(t) {
73 out.push(t.clone());
74 }
75 }
76 }
77 out.sort_by(|a, b| b.chars().count().cmp(&a.chars().count()).then(a.cmp(b)));
78 out
79 }
80}
81
82/// How discovery could not proceed.
83#[derive(Debug)]
84pub enum TaggerError {
85 /// a model file was not found; carries the resolver's instruction, which names repo, revision and size
86 Model(String),
87 /// the tagger or embedder failed on input
88 Inference(String),
89}
90
91impl std::fmt::Display for TaggerError {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match self {
94 TaggerError::Model(m) => write!(f, "model not available: {m}"),
95 TaggerError::Inference(m) => write!(f, "tagger failed: {m}"),
96 }
97 }
98}
99
100impl std::error::Error for TaggerError {}
101
102struct Span {
103 kind: String,
104 text: String,
105 vec: Vec<f32>,
106}
107
108/// Tag a sample, embed the spans, and build a per-kind codebook.
109///
110/// `k_ent`/`k_rel` are the reference's `ke`/`kr` (12 and 8) — upper bounds, which the solver clamps down when a
111/// kind has few spans.
112pub fn discover(sample: &[String], k_ent: usize, k_rel: usize) -> Result<RawSpec, TaggerError> {
113 let tagger_dir = crate::models::resolve("spo-tagger").map_err(|e| TaggerError::Model(e.to_string()))?;
114 let m2v_dir = crate::models::resolve("model2vec").map_err(|e| TaggerError::Model(e.to_string()))?;
115 let mut tagger = SpoTagger::load(&tagger_dir).map_err(|e| TaggerError::Inference(e.to_string()))?;
116 let embedder = Model2Vec::load(&m2v_dir).map_err(|e| TaggerError::Inference(e.to_string()))?;
117
118 let mut spans: Vec<Span> = Vec::new();
119 let mut routed: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
120 for doc in sample {
121 for sentence in doc.split(['.', ';', '!', '?', '\n']) {
122 let s = sentence.trim();
123 if s.is_empty() {
124 continue;
125 }
126 let tagged = tagger.tag(s).map_err(|e| TaggerError::Inference(e.to_string()))?;
127 for sp in tagged {
128 // QTY is a value on a scale and TIME is bucketed exactly without a model, so neither belongs in
129 // a semantic codebook. Counted rather than silently discarded, so the routing is visible.
130 if sp.kind == "QTY" || sp.kind == "TIME" {
131 *routed.entry(sp.kind).or_default() += 1;
132 continue;
133 }
134 if let Some(vec) = embedder.embed(&sp.text) {
135 spans.push(Span { kind: sp.kind, text: sp.text, vec });
136 }
137 }
138 }
139 }
140
141 let mut entity_clusters = Vec::new();
142 for kind in ENTITY_KINDS {
143 let (terms, embs) = by_kind(&spans, kind);
144 entity_clusters.extend(codebook(terms, embs, k_ent));
145 }
146 let (rel_terms, rel_embs) = by_kind(&spans, "REL");
147 let relation_clusters = codebook(rel_terms, rel_embs, k_rel);
148
149 Ok(RawSpec {
150 entity_clusters,
151 relation_clusters,
152 documents: sample.len(),
153 routed_away: routed.into_iter().collect(),
154 })
155}
156
157fn by_kind(spans: &[Span], kind: &str) -> (Vec<String>, Vec<Vec<f32>>) {
158 let mut terms = Vec::new();
159 let mut embs = Vec::new();
160 for s in spans.iter().filter(|s| s.kind == kind) {
161 terms.push(s.text.clone());
162 embs.push(s.vec.clone());
163 }
164 (terms, embs)
165}
166
167/// Cluster one kind: k-means++ prototypes, Sinkhorn assignment, members ordered by closeness.
168fn codebook(terms: Vec<String>, embs: Vec<Vec<f32>>, k: usize) -> Vec<RawCluster> {
169 if terms.len() < 4 {
170 return Vec::new();
171 }
172 let d = OtDiscover::new(terms, embs, k);
173 // 200 Sinkhorn iterations, matching the reference; assignment is the argmax of the transport plan
174 let (assign, _cost) = d.assign(200);
175 d.clusters(&assign, 12)
176 .into_iter()
177 .map(|c| RawCluster { label: c.label, terms: c.terms })
178 .collect()
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn surfaces_are_deduplicated_and_longest_first() {
187 // The gazetteer is built from observed spans, not from curated facets, so it survives whatever the
188 // curator merges or drops. Longest-first ordering is what makes a full mention beat its own prefix.
189 let spec = RawSpec {
190 entity_clusters: vec![
191 RawCluster {
192 label: "city".into(),
193 terms: vec!["Sootopolis".into(), "Sootopolis City".into(), "Sootopolis".into()],
194 },
195 RawCluster { label: "region".into(), terms: vec!["Johto".into()] },
196 ],
197 relation_clusters: Vec::new(),
198 documents: 2,
199 routed_away: Vec::new(),
200 };
201 let s = spec.surfaces();
202 assert_eq!(s, vec!["Sootopolis City", "Sootopolis", "Johto"], "{s:?}");
203 }
204
205 #[test]
206 fn a_raw_cluster_label_is_not_treated_as_a_facet_name() {
207 // Guards the design this module exists to respect: nothing here promotes a cluster label to a facet.
208 // The reference's raw spec contains `ph` and `ge`; only curation turns clusters into named types, so a
209 // RawSpec must expose clusters and surfaces and NOT a `categories` list.
210 let spec = RawSpec::default();
211 assert!(spec.entity_clusters.is_empty() && spec.surfaces().is_empty());
212 }
213}