Skip to main content

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}