Skip to main content

steeldb/
emergent.rs

1//! **Emergent ontology from prose** — discover facets from unlabelled natural language, with no model and
2//! no planted hints.
3//!
4//! Field sensing (`vocabulary::candidate_fields`) reads `- **Field:** value` markers. That works, but those
5//! markers are a schema someone already wrote into the document: sensing them is reading an answer key, not
6//! discovering structure. Real corpora are prose, and on prose field sensing returns nothing at all.
7//!
8//! This module discovers the vocabulary the hard way:
9//!
10//! 1. **Salience** — score every term by TF-IDF across the corpus. A term in every document distinguishes
11//!    nothing; a term in one document is noise. What survives is the vocabulary that carves the corpus up.
12//! 2. **Co-occurrence** — represent each term by the set of documents it appears in, and measure terms by
13//!    how much those sets overlap. Terms naming the same *kind* of thing occur in the same places.
14//! 3. **Agglomeration** — merge the closest clusters until the requested number remain, so the taxonomy is
15//!    built bottom-up out of the corpus rather than imposed on it.
16//! 4. **Labelling** — name each cluster by its most distinctive term, again by TF-IDF.
17//!
18//! Every step is deterministic and dependency-free, which is what lets it run in the browser. An
19//! embedding-based clusterer would be sharper, but it needs a tokenizer that cannot target wasm32 — and a
20//! demo that cannot run the real thing is worth less than a slightly blunter one that can.
21
22use std::collections::{HashMap, HashSet};
23
24/// A discovered group of co-occurring terms — a candidate facet before the MECE gate has ruled on it.
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct TermCluster {
27    /// most distinctive term in the cluster, used as the facet name
28    pub label: String,
29    /// members, most salient first
30    pub terms: Vec<String>,
31    /// fraction of the corpus in which at least one member appears
32    pub coverage: f64,
33    /// mean pairwise co-occurrence similarity — how tightly the group holds together
34    pub cohesion: f64,
35}
36
37/// Terms that appear capitalised mid-sentence are proper nouns: names of individuals, not names of kinds.
38///
39/// They belong in a facet's MEMBERS (they are the instances it collects) but make poor LABELS — labelling
40/// the battle cluster `gale` after a trainer's surname describes one competitor, not the category. A term is
41/// treated as a common noun when the corpus writes it in lowercase at least sometimes.
42pub fn common_nouns(docs: &[String]) -> HashSet<String> {
43    let mut lower_seen: HashSet<String> = HashSet::new();
44    for doc in docs {
45        for raw in doc.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '\'') {
46            let t = raw.trim_matches('-');
47            if t.len() < 3 {
48                continue;
49            }
50            // first character lowercase in the source text
51            if t.chars().next().map(|c| c.is_lowercase()).unwrap_or(false) {
52                lower_seen.insert(t.to_lowercase());
53            }
54        }
55    }
56    lower_seen
57}
58
59/// Remove URL runs before tokenising. A URL splits into `https`, the host, and path fragments; the path
60/// fragments are unique per link and fall to the document-frequency floor, but `https` (and `http`, `www`)
61/// recur across every link in the corpus and rose to the top of the salient terms — the records corpus, where
62/// 3423 of 7077 documents carry a link, discovered a category literally named `https`. The reference strips
63/// `https?://\S+` for the same reason; this does the same without a regex dependency, replacing each URL run
64/// with a space so it cannot contribute a token.
65fn scrub_urls(s: &str) -> String {
66    // URL schemes are ASCII, so a case-insensitive byte-prefix test needs no separate lowercased copy — which
67    // is what a first version got wrong, indexing `s` with offsets from a lowercased string of a different byte
68    // length. Work on `s` directly, one character at a time.
69    fn starts_url(rest: &str) -> bool {
70        // byte-slice comparison, so a multi-byte character straddling the prefix length cannot panic a string
71        // slice; schemes are ASCII, so this is exact
72        let b = rest.as_bytes();
73        (b.len() >= 8 && b[..8].eq_ignore_ascii_case(b"https://"))
74            || (b.len() >= 7 && b[..7].eq_ignore_ascii_case(b"http://"))
75            || (b.len() >= 4 && b[..4].eq_ignore_ascii_case(b"www."))
76    }
77    let mut out = String::with_capacity(s.len());
78    let mut rest = s;
79    while !rest.is_empty() {
80        if starts_url(rest) {
81            // a URL runs until the next whitespace
82            match rest.find(char::is_whitespace) {
83                Some(ws) => {
84                    out.push(' ');
85                    rest = &rest[ws..];
86                }
87                None => break,
88            }
89        } else {
90            let ch = rest.chars().next().unwrap();
91            out.push(ch);
92            rest = &rest[ch.len_utf8()..];
93        }
94    }
95    out
96}
97
98fn tokenize(s: &str) -> Vec<String> {
99    scrub_urls(s)
100        .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '\'')
101        .map(|w| w.trim_matches('-').to_lowercase())
102        .filter(|w| {
103            w.len() >= 3
104                && w.len() <= 28
105                && w.chars().next().map(|c| c.is_alphabetic()).unwrap_or(false)
106                // a bare number is a value, not a name for a kind of thing
107                && !w.chars().all(|c| c.is_ascii_digit())
108        })
109        .collect()
110}
111
112const STOP: &[&str] = &[
113    "the", "and", "for", "with", "was", "were", "this", "that", "from", "into", "are", "has", "had", "his",
114    "her", "its", "not", "but", "all", "any", "may", "can", "will", "each", "than", "then", "during",
115    "under", "over", "also", "which", "while", "their", "there", "been", "being", "who", "when", "what",
116    "how", "why", "per", "via", "such", "more", "most", "less", "other", "some", "one", "two", "three",
117    "against", "after", "before", "between", "both", "out", "off", "own", "same", "too", "very", "just",
118    "him", "she", "they", "them", "these", "those", "have", "does", "did", "doing", "would", "could",
119    "should", "must", "shall", "about", "above", "below", "again", "further", "once", "here", "only",
120    "remains", "stands", "recorded", "reported", "held", "took", "made", "including", "included",
121];
122
123/// Select the candidate mentions to cluster — the members of the reified situations.
124///
125/// This step deliberately does **no TF-IDF**. TF-IDF measures how well a term distinguishes one group from
126/// others, so it can only be applied once groups exist; using it to pick the input would be scoring terms
127/// against a partition that has not been computed yet. Selection is therefore a plain document-frequency
128/// window: a mention in nearly every situation separates nothing, and one appearing once cannot be a
129/// dimension. TF-IDF enters later, in [`name_cluster`], where it replaces an LLM naming call.
130fn salient_terms(docs: &[String], n_terms: usize) -> Vec<(String, HashSet<usize>)> {
131    let mut incidence: HashMap<String, HashSet<usize>> = HashMap::new();
132    let mut tf: HashMap<String, usize> = HashMap::new();
133    for (i, doc) in docs.iter().enumerate() {
134        for w in tokenize(doc) {
135            if STOP.contains(&w.as_str()) || GENERIC.contains(&w.as_str()) || LOCATIVES.contains(&w.as_str()) {
136                continue;
137            }
138            *tf.entry(w.clone()).or_default() += 1;
139            incidence.entry(w).or_default().insert(i);
140        }
141    }
142
143    let n = docs.len().max(1) as f64;
144    let min_df = 2usize;
145    let max_df = ((n * 0.85).ceil() as usize).max(min_df + 1);
146
147    // rank only by how often the mention occurs, inside the frequency window; no relevance weighting here
148    let mut scored: Vec<(String, f64)> = incidence
149        .iter()
150        .filter(|(_, docs_in)| docs_in.len() >= min_df && docs_in.len() <= max_df)
151        .map(|(term, _)| (term.clone(), *tf.get(term).unwrap_or(&1) as f64))
152        .collect();
153    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0)));
154    scored.truncate(n_terms);
155
156    scored
157        .into_iter()
158        .map(|(term, _)| {
159            let docs_in = incidence.remove(&term).unwrap_or_default();
160            (term, docs_in)
161        })
162        .collect()
163}
164
165/// Latent motifs: themes a situation can join through terms that travel together, with no keyword in common.
166///
167/// This is the paper's sixth dimension. The reference implementation gets motifs from SPLADE activations, which
168/// need a trained model; the paper permits optimal transport over the co-occurrence geometry as the alternative,
169/// and that is what this does — the same solver as [`discover`], at a lower epsilon so each term commits to one
170/// theme rather than hedging across all of them.
171///
172/// Returns `(name, member terms)`. A motif is named by its first member that is a corpus common noun, because a
173/// theme should read as a kind of thing rather than as a proper name.
174pub fn discover_motifs(docs: &[String], n_terms: usize, k: usize) -> Vec<(String, Vec<String>)> {
175    let (terms, vecs) = term_vectors(docs, n_terms);
176    if terms.len() < 2 || k == 0 {
177        return Vec::new();
178    }
179    let (_protos, assign, _cost) = crate::text::ot::codebook(&vecs, k, MOTIF_EPS);
180    let groups = assign.iter().copied().max().map_or(0, |m| m + 1);
181    let mut members: Vec<Vec<String>> = vec![Vec::new(); groups];
182    for (i, &a) in assign.iter().enumerate() {
183        members[a].push(terms[i].clone());
184    }
185    let commons = common_nouns(docs);
186    members
187        .into_iter()
188        .filter(|g| !g.is_empty())
189        .filter_map(|g| {
190            let name = g.iter().find(|t| commons.contains(*t)).or_else(|| g.first())?.clone();
191            (!name.is_empty()).then_some((name, g))
192        })
193        .collect()
194}
195
196/// Entropy regularisation for motifs, below [`DISCOVER_EPS`] so a term commits to a single theme.
197const MOTIF_EPS: f32 = 0.03;
198
199/// Entropy regularisation for discovery, from the reference implementation's `--eps` default. Lower makes
200/// each term commit to one facet; higher spreads it across several.
201pub const DISCOVER_EPS: f32 = 0.05;
202
203/// L2-normalised document-incidence vectors for the top salient terms, plus the terms themselves.
204///
205/// This is what lets optimal transport run with no embedding model. A term's "position" is simply the set of
206/// documents it appears in, written as a vector of 0s and 1s and normalised — so cosine similarity between
207/// two terms is exactly their co-occurrence. The Sinkhorn core takes a cost matrix and does not care where
208/// the geometry came from, and this geometry needs no tokenizer, which is what makes it run in a browser.
209pub fn term_vectors(docs: &[String], n_terms: usize) -> (Vec<String>, Vec<Vec<f32>>) {
210    let picked = salient_terms(docs, n_terms);
211    let n = docs.len().max(1);
212    let mut names = Vec::with_capacity(picked.len());
213    let mut vecs = Vec::with_capacity(picked.len());
214    for (term, docs_in) in picked {
215        let mut v = vec![0f32; n];
216        for i in &docs_in {
217            if *i < n {
218                v[*i] = 1.0;
219            }
220        }
221        let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt() + 1e-9;
222        vecs.push(v.into_iter().map(|x| x / norm).collect());
223        names.push(term);
224    }
225    (names, vecs)
226}
227
228/// Quantities: a number followed by a unit, returned as `(start, end, canonical field)` byte ranges.
229///
230/// The paper treats quantities as their own dimension, and the reference pipeline routes them away from the
231/// semantic codebook entirely — a measurement is not a kind of thing, it is a value with a scale. Extracting
232/// them separately is also the only way a numeric range predicate has anything to range over: without this,
233/// "1082 m" is three unremarkable characters and a unit nobody recorded.
234pub fn quantity_spans(doc: &str) -> Vec<(usize, usize, String)> {
235    const UNITS: &[(&str, &str)] = &[
236        ("mm", "length_mm"), ("cm", "length_cm"), ("km", "length_km"), ("m", "length_m"),
237        ("kg", "mass_kg"), ("g", "mass_g"), ("t", "mass_t"),
238        ("°c", "temp_c"), ("°f", "temp_f"), ("c", "temp_c"),
239        ("minutes", "minutes"), ("minute", "minutes"), ("min", "minutes"),
240        ("hours", "hours"), ("hour", "hours"), ("seconds", "seconds"),
241        ("mm/yr", "rainfall_mm"), ("%", "percent"),
242    ];
243    let b = doc.as_bytes();
244    let mut out = Vec::new();
245    let mut i = 0usize;
246    while i < b.len() {
247        // start of a number, not mid-word
248        if b[i].is_ascii_digit() && (i == 0 || !(b[i - 1] as char).is_alphanumeric()) {
249            // A leading minus belongs to the number. Dropping it turned "-7 °C" into 7 °C, so a sub-zero
250            // reading indexed as above zero — a wrong answer, not a missing one. Only counts when the sign
251            // directly precedes the digits and itself follows a boundary, so the hyphen in "11-minute" and
252            // ranges like "5-10" are not mistaken for a sign.
253            let mut start = i;
254            if i > 0 && b[i - 1] == b'-' {
255                let before_sign = i >= 2 && !(b[i - 2] as char).is_alphanumeric() && b[i - 2] != b'-';
256                if i == 1 || before_sign {
257                    start = i - 1;
258                }
259            }
260            let mut j = i;
261            while j < b.len() && (b[j].is_ascii_digit() || b[j] == b'.' || b[j] == b',') {
262                j += 1;
263            }
264            let num_end = j;
265            // optional separators (space, hyphen, non-breaking space) then the unit
266            let mut k = j;
267            while k < b.len() && (b[k] == b' ' || b[k] == b'-') {
268                k += 1;
269            }
270            if k < b.len() && doc.is_char_boundary(k) {
271                let rest = &doc[k..];
272                let unit_len = rest
273                    .char_indices()
274                    .take_while(|(_, c)| c.is_alphabetic() || *c == '°' || *c == '%' || *c == '/')
275                    .map(|(bi, c)| bi + c.len_utf8())
276                    .last()
277                    .unwrap_or(0);
278                if unit_len > 0 {
279                    let unit = rest[..unit_len].to_lowercase();
280                    // longest unit match wins, so "km" is not read as "m"
281                    let mut best: Option<(&str, usize)> = None;
282                    for (u, field) in UNITS {
283                        if unit == *u && best.map(|(_, l)| u.len() > l).unwrap_or(true) {
284                            best = Some((field, u.len()));
285                        }
286                    }
287                    if let Some((field, ulen)) = best {
288                        let end = k + ulen;
289                        if doc.is_char_boundary(start) && doc.is_char_boundary(end) {
290                            out.push((start, end, field.to_string()));
291                            i = end;
292                            continue;
293                        }
294                    }
295                }
296            }
297            i = num_end.max(i + 1);
298            continue;
299        }
300        i += 1;
301    }
302    out
303}
304
305/// Whole-word containment test, for checking whether a document participates in a term group.
306pub fn contains_term(hay: &str, needle: &str) -> bool {
307    !word_spans(hay, needle).is_empty()
308}
309
310/// The whole words of a document, lowercased — the cheap key for deciding which phrases could occur in it.
311pub fn word_set(doc: &str) -> std::collections::HashSet<String> {
312    doc.split(|c: char| !c.is_alphanumeric())
313        .filter(|w| !w.is_empty())
314        .map(|w| w.chars().flat_map(|c| c.to_lowercase()).collect())
315        .collect()
316}
317
318/// A gazetteer compiled for one-pass matching.
319///
320/// Scanning a document once per phrase is O(document × phrases), and on a large corpus the gazetteer grows into
321/// the thousands, so projection was quadratic in corpus size — the reference implementation avoids this only by
322/// having a neural tagger read each document once, which the model-free build has no equivalent for. An
323/// Aho-Corasick automaton is that equivalent: built once from every phrase, it finds all of them in a single
324/// pass over the text, independent of how many phrases there are.
325///
326/// The automaton is a prefilter. It finds substring occurrences; [`word_spans`] then confirms word boundaries
327/// on the few phrases that occurred, so the result is exactly `mentions.iter().filter(|m| contains_term(doc,
328/// m))` would give — a word-boundary match is necessarily a substring match — at a fraction of the cost.
329pub struct MentionMatcher {
330    ac: aho_corasick::AhoCorasick,
331    surfaces: Vec<String>,
332}
333
334impl MentionMatcher {
335    pub fn new(mentions: &[String]) -> MentionMatcher {
336        let surfaces: Vec<String> = mentions.to_vec();
337        // lowercase the patterns and match against lowercased text, mirroring `word_spans`, whose
338        // case-folding is full Unicode `to_lowercase` rather than ASCII-only.
339        let lowered: Vec<String> = surfaces.iter().map(|s| s.to_lowercase()).collect();
340        let ac = aho_corasick::AhoCorasick::builder()
341            .match_kind(aho_corasick::MatchKind::Standard)
342            .build(&lowered)
343            .expect("gazetteer automaton");
344        MentionMatcher { ac, surfaces }
345    }
346
347    /// Which registered surfaces occur in `doc` as whole words, in registration order, deduplicated.
348    pub fn present<'a>(&'a self, doc: &str) -> Vec<&'a String> {
349        let lower = doc.to_lowercase();
350        let mut seen = vec![false; self.surfaces.len()];
351        // one pass finds every candidate; overlapping matches are wanted, since a prefix and the phrase
352        // containing it can both be registered surfaces
353        for m in self.ac.find_overlapping_iter(&lower) {
354            let id = m.pattern().as_usize();
355            if seen[id] {
356                continue;
357            }
358            if contains_term(doc, &self.surfaces[id]) {
359                seen[id] = true;
360            }
361        }
362        self.surfaces.iter().enumerate().filter(|(i, _)| seen[*i]).map(|(_, s)| s).collect()
363    }
364}
365
366/// Which of `mentions` occur in `doc`, as whole words, in input order. Convenience for a single document; when
367/// projecting many, build one [`MentionMatcher`] and reuse it rather than rebuilding the automaton each call.
368pub fn mentions_present<'a>(doc: &str, mentions: &'a [String]) -> Vec<&'a String> {
369    // borrow-preserving: match on a matcher built from the same slice, then map ids back to the caller's refs
370    let matcher = MentionMatcher::new(mentions);
371    let present: std::collections::HashSet<&str> =
372        matcher.present(doc).into_iter().map(|s| s.as_str()).collect();
373    mentions.iter().filter(|m| present.contains(m.as_str())).collect()
374}
375
376/// Relation verbs worth binding a role to. A curated list rather than a morphological guess: "-ed" also ends
377/// plenty of adjectives ("distinctive", "detailed"), and a false relation is worse than a missing one because
378/// it asserts a direction that was never stated.
379const REL_VERBS: &[(&str, &str)] = &[
380    ("defeated", "defeated"), ("beat", "defeated"), ("faced", "faced"), ("met", "faced"),
381    ("documented", "documented"), ("recorded", "recorded"), ("measured", "measured"),
382    ("observed", "observed"), ("found", "observed"), ("held", "held_at"), ("hosted", "held_at"),
383    ("used", "used"), ("led", "used"), ("answered", "answered_with"), ("commanded", "commanded"),
384    ("permitted", "permitted"), ("banned", "banned"), ("restricted", "restricted"),
385    ("competed", "competed_in"), ("entered", "competed_in"), ("won", "won"), ("secured", "secured"),
386    ("contributes", "contributes_to"), ("supplies", "supplies"), ("operates", "operates"),
387];
388
389/// One directional relation read out of a sentence.
390#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
391pub struct Relation {
392    /// canonical predicate name
393    pub verb: String,
394    /// the mention on the acting side
395    pub actor: String,
396    /// the mention on the receiving side
397    pub target: String,
398}
399
400/// Extract directional relations from prose by pattern: mention, relation verb, mention.
401///
402/// This is deliberately not a model. The span tagger produces better relations, but it cannot run in a
403/// browser, and leaving the dimension empty misrepresents the system more than a conservative extractor does.
404///
405/// Direction is the whole point. The mention on the left of the verb becomes the actor (`+`), the one on the
406/// right the target (`-`). Storing them as separate tags is what makes a reversed relation
407/// *unrepresentable* rather than merely wrong: there is no tag that means "supplied by" hiding inside
408/// `rel/supplies/+`.
409///
410/// Only the nearest mention on each side is taken, and only within a single sentence, because a relation
411/// inferred across a sentence boundary is a guess about coreference rather than something the text states.
412pub fn relation_spans(doc: &str, mentions: &[String]) -> Vec<Relation> {
413    relation_spans_with(doc, &MentionMatcher::new(mentions))
414}
415
416/// As [`relation_spans`], but reusing a [`MentionMatcher`] built once for the whole corpus.
417///
418/// Building the automaton is O(total gazetteer text); doing it per document would put the quadratic back. When
419/// projecting many documents, build the matcher once and pass it here.
420pub fn relation_spans_with(doc: &str, matcher: &MentionMatcher) -> Vec<Relation> {
421    let mut out: Vec<Relation> = Vec::new();
422    for sentence in doc.split(['.', ';', '!', '?', '\n']) {
423        if sentence.trim().is_empty() {
424            continue;
425        }
426        // locate every mention in this sentence, longest first so a full name beats its prefix. The matcher
427        // returns exactly the surfaces present as whole words, in registration order (which is longest-first),
428        // so the containment dedup below behaves as it did when this looped over the entire gazetteer.
429        let mut found: Vec<(usize, usize, String)> = Vec::new();
430        for m in matcher.present(sentence) {
431            for (s, e) in word_spans(sentence, m) {
432                if !found.iter().any(|(fs, fe, _)| s >= *fs && e <= *fe) {
433                    found.push((s, e, m.clone()));
434                }
435            }
436        }
437        // The corpus gazetteer only keeps mentions that RECUR, which is right for building vocabulary and
438        // wrong for reading a relation: "Juan Tide defeated Cynthia Ward" states a fact about two people
439        // whether or not either name appears twice. So names found in this sentence count too.
440        for (s, e, name) in local_mentions(sentence) {
441            if !found.iter().any(|(fs, fe, _)| s < *fe && e > *fs) {
442                found.push((s, e, name));
443            }
444        }
445        if found.len() < 2 {
446            continue;
447        }
448        found.sort_by_key(|(s, _, _)| *s);
449
450        for (raw, canon) in REL_VERBS {
451            for (vs, ve) in word_spans(sentence, raw) {
452                // nearest mention ending before the verb, and nearest starting after it
453                let actor = found.iter().filter(|(_, e, _)| *e <= vs).next_back();
454                let target = found.iter().find(|(s, _, _)| *s >= ve);
455                if let (Some((_, _, a)), Some((_, _, t))) = (actor, target) {
456                    if a != t {
457                        out.push(Relation { verb: canon.to_string(), actor: a.clone(), target: t.clone() });
458                    }
459                }
460            }
461        }
462    }
463    out.dedup_by(|a, b| a.verb == b.verb && a.actor == b.actor && a.target == b.target);
464    out
465}
466
467/// Capitalised runs inside a single sentence — mentions that need no corpus-wide support to be real.
468///
469/// Used for relation extraction, where a one-off name is still a participant. Deliberately not used to build
470/// the vocabulary, because a mention seen once cannot define a retrieval dimension.
471///
472/// The first word of a sentence is skipped: its capital is grammar, not a name.
473pub fn local_mentions(sentence: &str) -> Vec<(usize, usize, String)> {
474    // Iterate CHARACTERS, not bytes. Casting a raw byte to char treats a UTF-8 continuation byte as a
475    // Latin-1 character, so word boundaries land mid-character and slicing panics — "Pokémon" and "28 °C"
476    // both trigger it.
477    let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '-';
478
479    let mut words: Vec<(usize, usize, &str)> = Vec::new();
480    let mut cur: Option<usize> = None;
481    for (i, c) in sentence.char_indices() {
482        if is_word(c) {
483            if cur.is_none() {
484                cur = Some(i);
485            }
486        } else if let Some(st) = cur.take() {
487            words.push((st, i, &sentence[st..i]));
488        }
489    }
490    if let Some(st) = cur {
491        words.push((st, sentence.len(), &sentence[st..]));
492    }
493
494    // Runs are collected as word-index ranges first, so the sentence-opening word can be reconsidered once we
495    // know how long its run turned out to be.
496    let mut runs: Vec<(usize, usize)> = Vec::new();
497    let mut run: Option<(usize, usize)> = None;
498    let mut prev_end: Option<usize> = None;
499    for (wi, (st, en, w)) in words.iter().enumerate() {
500        let capped = w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) && w.chars().count() > 1;
501        // Punctuation ends a run even between two capitals. Without this, "in Violet City, Johto, Juan Tide
502        // defeated ..." reads as one seven-word name, and the relation gets an actor that is three separate
503        // things joined by commas.
504        let punctuated = prev_end
505            .map(|pe| sentence[pe..*st].chars().any(|c| !c.is_whitespace()))
506            .unwrap_or(false);
507        if punctuated {
508            if let Some(r) = run.take() {
509                runs.push(r);
510            }
511        }
512        if capped {
513            run = Some(match run {
514                Some((rs, _)) => (rs, wi),
515                None => (wi, wi),
516            });
517        } else if let Some(r) = run.take() {
518            runs.push(r);
519        }
520        prev_end = Some(*en);
521    }
522    if let Some(r) = run {
523        runs.push(r);
524    }
525
526    // Determiners that open a sentence are capitalised by grammar. Previously the whole first word was skipped,
527    // which cost "Morty Shade defeated Wallace Gale" its actor: "Morty" was dropped and the relation recorded
528    // `shade`. A sentence-initial capital now joins its run, and only a leading determiner is removed — so
529    // "Milotic is not permitted" still yields Milotic, which is genuinely the subject.
530    const DETERMINERS: &[&str] = &[
531        "the", "a", "an", "this", "that", "these", "those", "their", "its", "his", "her", "our", "your", "my",
532        "it", "they", "we", "he", "she", "there", "then", "when", "where", "what", "which", "who",
533    ];
534    let mut out: Vec<(usize, usize, String)> = Vec::new();
535    for (first, last) in runs {
536        let mut first = first;
537        if first == 0 && DETERMINERS.contains(&words[0].2.to_lowercase().as_str()) {
538            // "The Indigo Invitational" is a name wearing an article; "The survey" is not a name at all
539            first += 1;
540        }
541        if first > last {
542            continue;
543        }
544        let (rs, re) = (words[first].0, words[last].1);
545        out.push((rs, re, sentence[rs..re].to_string()));
546    }
547    out
548}
549
550/// Temporal loci: years and quarters, as `(start, end, bucket token)` byte ranges.
551///
552/// Dates are the one dimension that needs neither a model nor a gazetteer — a four-digit year is
553/// unambiguous. They are bucketed rather than stored as exact instants, because the engine matches sets: a
554/// query asks for `time/2026/q3`, not for an interval comparison. Bucketing is what turns a continuous axis
555/// into something a bitmap can intersect.
556pub fn temporal_spans(doc: &str) -> Vec<(usize, usize, String)> {
557    let b = doc.as_bytes();
558    let mut out: Vec<(usize, usize, String)> = Vec::new();
559
560    // quarters: Q1..Q4, optionally followed by a year
561    let mut i = 0usize;
562    while i + 1 < b.len() {
563        if (b[i] == b'Q' || b[i] == b'q') && b[i + 1].is_ascii_digit() {
564            let q = (b[i + 1] - b'0') as u32;
565            let starts_word = i == 0 || !(b[i - 1] as char).is_alphanumeric();
566            if (1..=4).contains(&q) && starts_word {
567                let end = i + 2;
568                // look ahead for a year so the bucket can be fully qualified
569                let tail = &doc[end..].trim_start();
570                let year: Option<u32> = tail
571                    .split(|c: char| !c.is_ascii_digit())
572                    .next()
573                    .filter(|t| t.len() == 4)
574                    .and_then(|t| t.parse().ok())
575                    .filter(|y| (1900..2200).contains(y));
576                let token = match year {
577                    Some(y) => format!("time/{y}/q{q}"),
578                    None => format!("time/q{q}"),
579                };
580                if doc.is_char_boundary(i) && doc.is_char_boundary(end) {
581                    out.push((i, end, token));
582                }
583                i = end;
584                continue;
585            }
586        }
587        i += 1;
588    }
589
590    // bare years
591    let mut j = 0usize;
592    while j + 3 < b.len() {
593        if b[j].is_ascii_digit() {
594            let before_ok = j == 0 || !(b[j - 1] as char).is_alphanumeric();
595            let end = j + 4;
596            let after_ok = end >= b.len() || !(b[end] as char).is_alphanumeric();
597            if before_ok && after_ok && b[j..end].iter().all(|c| c.is_ascii_digit()) {
598                if let Ok(y) = doc[j..end].parse::<u32>() {
599                    if (1900..2200).contains(&y) && !out.iter().any(|(s, e, _)| j >= *s && end <= *e) {
600                        out.push((j, end, format!("time/{y}")));
601                    }
602                }
603                j = end;
604                continue;
605            }
606        }
607        j += 1;
608    }
609    out.sort_by_key(|(s, _, _)| *s);
610    out
611}
612
613/// Mine multi-word proper-noun mentions — a **gazetteer** grown from the corpus itself.
614///
615/// Word-level matching shatters real entities: "Sootopolis City" becomes "Sootopolis" plus "City", and the
616/// engine then believes it saw two unrelated things. A mention is a run of capitalised words, so the longest
617/// run wins and the parts are never emitted separately.
618///
619/// A run is kept only if it appears at least `min_count` times across the corpus. That threshold is what
620/// stops an ordinary sentence-initial word from being promoted to an entity: "The" starts many sentences but
621/// never forms a repeated multi-word run with what follows.
622pub fn mine_gazetteer(docs: &[String], min_count: usize) -> Vec<String> {
623    let mut counts: HashMap<String, usize> = HashMap::new();
624    for doc in docs {
625        for sentence in doc.split(['.', '\n', ';', '!', '?']) {
626            let words: Vec<&str> = sentence.split_whitespace().collect();
627            let mut run: Vec<&str> = Vec::new();
628            let mut first = true;
629            for w in words {
630                let clean = w.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '-');
631                let cap = clean
632                    .chars()
633                    .next()
634                    .map(|c| c.is_uppercase())
635                    .unwrap_or(false)
636                    && clean.len() > 1;
637                // a sentence-initial capital carries no information, so it cannot start a run
638                if cap && !(first && run.is_empty()) {
639                    run.push(clean);
640                } else {
641                    if run.len() >= 2 {
642                        *counts.entry(run.join(" ")).or_default() += 1;
643                    }
644                    run.clear();
645                }
646                first = false;
647            }
648            if run.len() >= 2 {
649                *counts.entry(run.join(" ")).or_default() += 1;
650            }
651        }
652    }
653    let mut kept: Vec<String> =
654        counts.into_iter().filter(|(_, n)| *n >= min_count).map(|(s, _)| s).collect();
655    // longest first, so matching prefers the full mention over any prefix of it
656    kept.sort_by(|a, b| b.len().cmp(&a.len()).then(a.cmp(b)));
657    kept
658}
659
660/// Generic vocabulary that looks like a category but names no kind of thing. The reference pipeline routes
661/// this class away from the codebook rather than clustering it.
662/// Prepositions and locatives. These belong with `STOP`: a function word is not a kind of thing, so it can
663/// neither name a category nor usefully join one, and one that occurs in most documents adds only noise to the
664/// co-occurrence geometry. The list already held `during`, `under`, `over` and `between`; the omissions showed
665/// up as a corpus whose discovered category was `near/*`.
666pub const LOCATIVES: &[&str] = &[
667    "near", "nearby", "across", "along", "around", "through", "throughout", "toward", "towards",
668    "beside", "behind", "beyond", "upon", "onto", "inside", "outside", "amid", "among", "amongst",
669    "beneath", "underneath", "alongside", "opposite", "past", "since", "until", "till", "unto",
670];
671
672pub const GENERIC: &[&str] = &[
673    "within", "presence", "data", "contributes", "distribution", "environmental", "understanding",
674    "preferences", "observation", "site", "location", "conditions", "period", "mean", "documented",
675    "recorded", "measured", "reported", "described", "including", "various", "distinctive", "populations",
676    "ecological", "information", "details", "features", "aspects", "elements", "factors", "values",
677    "results", "analysis", "summary", "overview", "context", "purposes", "requirements",
678];
679
680/// Find every whole-word occurrence of `needle` in `hay`, as byte ranges.
681///
682/// Whole-word only: a substring match would highlight "it" inside "submitted".
683pub fn word_spans(hay: &str, needle: &str) -> Vec<(usize, usize)> {
684    let mut out = Vec::new();
685    if needle.is_empty() {
686        return out;
687    }
688    let lower = hay.to_lowercase();
689    let pat = needle.to_lowercase();
690    let bytes = lower.as_bytes();
691    let mut from = 0usize;
692    while let Some(rel) = lower[from..].find(&pat) {
693        let s = from + rel;
694        let e = s + pat.len();
695        let before_ok = s == 0 || !(bytes[s - 1] as char).is_alphanumeric();
696        let after_ok = e >= bytes.len() || !(bytes[e] as char).is_alphanumeric();
697        // only keep it if the byte range is also a char boundary in the ORIGINAL string
698        if before_ok && after_ok && hay.is_char_boundary(s) && hay.is_char_boundary(e) {
699            out.push((s, e));
700        }
701        from = s + pat.len().max(1);
702        if from >= lower.len() {
703            break;
704        }
705    }
706    out
707}
708
709/// The salience ranking on its own, for callers that want to show the intermediate step: `(term, score,
710/// document frequency)`, most salient first.
711pub fn salient(docs: &[String], n_terms: usize) -> Vec<(String, f64, usize)> {
712    let picked = salient_terms(docs, n_terms);
713    let n = docs.len().max(1) as f64;
714    picked
715        .into_iter()
716        .map(|(term, docs_in)| {
717            let d = docs_in.len();
718            let idf = ((n + 1.0) / (d as f64 + 1.0)).ln() + 1.0;
719            (term, idf, d)
720        })
721        .collect()
722}
723
724/// Name a cluster by the member term most exclusive to it — TF-IDF where a "document" is a CLUSTER.
725///
726/// This is the step that replaces an LLM naming call. The reference pipeline asked a model to invent a facet
727/// name; here the corpus decides. A term scores highly when it is frequent inside this cluster and rare in
728/// the other clusters, which is exactly what makes it a usable label for the group.
729///
730/// Running TF-IDF against the raw documents instead would answer a different question — "which words are
731/// unusual in this corpus" — and can hand back a term that several clusters share.
732///
733/// Proper nouns are demoted: a facet names a KIND, and an always-capitalised term is an instance inside the
734/// kind, not the name of it.
735pub fn name_cluster(
736    members: &[String],
737    tf_in_cluster: &HashMap<String, usize>,
738    clusters_containing: &HashMap<String, usize>,
739    n_clusters: usize,
740    cluster_size: usize,
741    commons: &HashSet<String>,
742    exclusivity: &HashMap<String, f64>,
743) -> String {
744    let n = n_clusters.max(1) as f64;
745    let size = cluster_size.max(1) as f64;
746
747    // A label has to be specific to its own group. `city` occurred in every battle document AND every survey
748    // document ("Ecruteak City", "Sootopolis City"), so ranking by frequency alone named the battle category
749    // `city/*` — a label that describes the corpus rather than the category, and the worst kind of wrong
750    // because it reads as meaningful. Terms whose documents mostly sit in OTHER groups are therefore not
751    // eligible to name this one. If that leaves nothing, every member is eligible again, since a group with no
752    // specific term still needs a name.
753    const MIN_EXCLUSIVITY: f64 = 0.8;
754    let confined: Vec<String> = members
755        .iter()
756        .filter(|t| exclusivity.get(*t).copied().unwrap_or(1.0) >= MIN_EXCLUSIVITY)
757        .cloned()
758        .collect();
759    let pool: &[String] = if confined.is_empty() { members } else { &confined };
760
761    let mut scored: Vec<(String, f64)> = pool
762        .iter()
763        .map(|t| {
764            let tf = *tf_in_cluster.get(t).unwrap_or(&1) as f64;
765            let dfc = *clusters_containing.get(t).unwrap_or(&1) as f64;
766            let idf = ((n + 1.0) / (dfc + 1.0)).ln() + 1.0;
767            // A label must describe MOST of its group. Ranking by raw frequency first picked `city` for the
768            // survey cluster: a frequent sub-part rather than the kind. Coverage of the cluster's own
769            // situations leads, and exclusivity across clusters only breaks ties.
770            let coverage = (tf / size).min(1.0);
771            let common_bonus = if commons.contains(t) { 1.3 } else { 1.0 };
772            (t.clone(), coverage * common_bonus + 0.12 * idf)
773        })
774        .collect();
775    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0)));
776    scored.first().map(|(t, _)| t.clone()).unwrap_or_default()
777}
778
779/// Cosine similarity of two document-incidence sets.
780fn cosine(a: &HashSet<usize>, b: &HashSet<usize>) -> f64 {
781    if a.is_empty() || b.is_empty() {
782        return 0.0;
783    }
784    let inter = a.intersection(b).count() as f64;
785    inter / ((a.len() as f64).sqrt() * (b.len() as f64).sqrt())
786}
787
788/// One node of the discovered taxonomy. Leaves are the clusters that survived the cut; internal nodes are
789/// the merges that built them, so the shape is the corpus's own hierarchy rather than an imposed one.
790#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
791pub struct TreeNode {
792    /// cluster label for a leaf; empty for an internal merge node
793    pub label: String,
794    /// average-linkage similarity at which this merge happened (0 for leaves)
795    pub height: f64,
796    /// number of member terms beneath this node
797    pub size: usize,
798    pub children: Vec<TreeNode>,
799}
800
801/// Build the full dendrogram, continuing past the requested cut all the way to a single root.
802///
803/// The cut at `n_clusters` marks which nodes become facets; everything above it is the super-structure the
804/// corpus implies. Returning the whole tree lets a viewer show both the accepted categories and how they
805/// would have combined, which is the picture agglomerative clustering actually produces.
806pub fn discover_hierarchy(docs: &[String], n_terms: usize, n_clusters: usize) -> Option<TreeNode> {
807    let terms = salient_terms(docs, n_terms.clamp(2, 400));
808    if terms.len() < 2 {
809        return None;
810    }
811    let cut = n_clusters.clamp(1, terms.len());
812    let commons = common_nouns(docs);
813
814    // active[i] = (members, doc incidence, node)
815    let mut active: Vec<(Vec<usize>, HashSet<usize>, TreeNode)> = terms
816        .iter()
817        .enumerate()
818        .map(|(i, (t, d))| {
819            (vec![i], d.clone(), TreeNode { label: t.clone(), height: 0.0, size: 1, children: Vec::new() })
820        })
821        .collect();
822
823    let avg_linkage = |a: &[usize], b: &[usize]| -> f64 {
824        let mut sum = 0.0;
825        for x in a {
826            for y in b {
827                sum += cosine(&terms[*x].1, &terms[*y].1);
828            }
829        }
830        sum / (a.len() * b.len()) as f64
831    };
832
833    // when the count reaches the cut, label the surviving groups — these are the facets
834    let mut labelled = false;
835    while active.len() > 1 {
836        if active.len() == cut && !labelled {
837            labelled = true;
838            let n_c = active.len();
839            let mut tf: HashMap<String, usize> = HashMap::new();
840            let mut containing: HashMap<String, usize> = HashMap::new();
841            for (members, docs_in, _) in &active {
842                let mut seen = HashSet::new();
843                for m in members {
844                    let t = &terms[*m].0;
845                    let c = docs_in.iter().filter_map(|i| docs.get(*i)).filter(|d| !word_spans(d, t).is_empty()).count();
846                    *tf.entry(t.clone()).or_default() += c.max(1);
847                    seen.insert(t.clone());
848                }
849                for t in seen {
850                    *containing.entry(t).or_default() += 1;
851                }
852            }
853            // the same specificity rule the flat discovery uses, computed before the mutable pass
854            let excl: Vec<HashMap<String, f64>> = (0..active.len())
855                .map(|ci| {
856                    let others: HashSet<usize> = active
857                        .iter()
858                        .enumerate()
859                        .filter(|(cj, _)| *cj != ci)
860                        .flat_map(|(_, (_, docs_j, _))| docs_j.iter().copied())
861                        .collect();
862                    active[ci]
863                        .0
864                        .iter()
865                        .map(|m| {
866                            let (term, term_docs) = &terms[*m];
867                            let own = term_docs.iter().filter(|d| !others.contains(d)).count() as f64;
868                            (term.clone(), own / term_docs.len().max(1) as f64)
869                        })
870                        .collect()
871                })
872                .collect();
873            for (ci, (members, docs_in, node)) in active.iter_mut().enumerate() {
874                let names: Vec<String> = members.iter().map(|m| terms[*m].0.clone()).collect();
875                node.label =
876                    name_cluster(&names, &tf, &containing, n_c, docs_in.len(), &commons, &excl[ci]);
877            }
878        }
879
880        let mut best: Option<(usize, usize, f64)> = None;
881        for i in 0..active.len() {
882            for j in (i + 1)..active.len() {
883                let s = avg_linkage(&active[i].0, &active[j].0);
884                if best.map(|(_, _, bs)| s > bs).unwrap_or(true) {
885                    best = Some((i, j, s));
886                }
887            }
888        }
889        let Some((i, j, sim)) = best else { break };
890        let (mj, dj, nj) = active.remove(j);
891        let (_, _, ni) = &active[i];
892        let merged = TreeNode {
893            label: String::new(),
894            height: sim,
895            size: ni.size + nj.size,
896            children: vec![active[i].2.clone(), nj],
897        };
898        active[i].0.extend(mj);
899        active[i].1.extend(dj);
900        active[i].2 = merged;
901    }
902
903    active.into_iter().next().map(|(_, _, n)| n)
904}
905
906/// Discover `n_clusters` candidate facets from prose.
907///
908/// `n_terms` caps how much vocabulary is considered; agglomeration is O(n_terms^3) in the worst case, so the
909/// cap is what keeps this interactive in a browser.
910pub fn discover(docs: &[String], n_terms: usize, n_clusters: usize) -> Vec<TermCluster> {
911    let terms = salient_terms(docs, n_terms.clamp(2, 400));
912    if terms.len() < 2 || n_clusters == 0 {
913        return Vec::new();
914    }
915
916    // L2-normalised document-incidence vectors. A term's position is the set of documents it appears in, so
917    // cosine between two terms is exactly their co-occurrence — the geometry the transport plan runs over, and
918    // it needs no embedding model, which is what lets this run in a browser.
919    let n_docs = docs.len().max(1);
920    let vecs: Vec<Vec<f32>> = terms
921        .iter()
922        .map(|(_, docs_in)| {
923            let mut v = vec![0f32; n_docs];
924            for i in docs_in {
925                if *i < n_docs {
926                    v[*i] = 1.0;
927                }
928            }
929            let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt() + 1e-9;
930            v.into_iter().map(|x| x / norm).collect()
931        })
932        .collect();
933
934    // k-means++ prototypes, then entropy-regularised optimal transport onto them, then argmax over the plan.
935    // This is the reference pipeline's `cluster()` (python/splade/spo_sinkhorn.py): eps 0.05, 200 Sinkhorn
936    // iterations, 60 k-means iterations, cost `1 - cosine`, uniform marginals on both sides.
937    //
938    // It replaced average-linkage agglomerative clustering, which was never the paper's method. Linkage was
939    // adopted here to stop one group chaining through weak similarities and swallowing the corpus, but the
940    // uniform TARGET marginal rules that out structurally: no prototype can absorb more than its 1/k share of
941    // the mass, so collapse is prevented by the solver rather than patched by a linkage floor.
942    let (_protos, assign, _cost) = crate::text::ot::codebook(&vecs, n_clusters, DISCOVER_EPS);
943
944    // group the terms by the target each was transported to; a prototype that won nothing is not a facet
945    let k = assign.iter().copied().max().map_or(0, |m| m + 1);
946    let mut clusters: Vec<(Vec<usize>, HashSet<usize>)> = vec![(Vec::new(), HashSet::new()); k];
947    for (i, &a) in assign.iter().enumerate() {
948        clusters[a].0.push(i);
949        clusters[a].1.extend(terms[i].1.iter().copied());
950    }
951    clusters.retain(|(members, _)| !members.is_empty());
952
953    let commons = common_nouns(docs);
954    let n = docs.len().max(1) as f64;
955
956    // Cluster-level statistics for naming: how often each term occurs inside its own cluster, and how many
957    // clusters mention it at all. Both are needed before any cluster can be named, which is precisely why
958    // naming cannot happen during selection.
959    let n_clusters_final = clusters.len();
960    let mut tf_in_cluster: HashMap<String, usize> = HashMap::new();
961    let mut clusters_containing: HashMap<String, usize> = HashMap::new();
962    for (members, docs_in) in &clusters {
963        let mut seen: HashSet<String> = HashSet::new();
964        for m in members {
965            let term = &terms[*m].0;
966            // occurrences of this member inside the cluster's own situations
967            let count = docs_in
968                .iter()
969                .filter_map(|i| docs.get(*i))
970                .filter(|d| word_spans(d, term).len() > 0)
971                .count();
972            *tf_in_cluster.entry(term.clone()).or_default() += count.max(1);
973            seen.insert(term.clone());
974        }
975        for t in seen {
976            *clusters_containing.entry(t).or_default() += 1;
977        }
978    }
979    // For each cluster, how specific each member term is to it: the share of the term's own documents that no
980    // OTHER cluster claims. Measuring against the cluster's own union would be vacuous, because that union is
981    // built from its members' documents and every member would score 1.0.
982    let exclusivity: Vec<HashMap<String, f64>> = (0..clusters.len())
983        .map(|ci| {
984            let others: HashSet<usize> = clusters
985                .iter()
986                .enumerate()
987                .filter(|(cj, _)| *cj != ci)
988                .flat_map(|(_, (_, docs_j))| docs_j.iter().copied())
989                .collect();
990            clusters[ci]
991                .0
992                .iter()
993                .map(|m| {
994                    let (term, term_docs) = &terms[*m];
995                    let own = term_docs.iter().filter(|d| !others.contains(d)).count() as f64;
996                    (term.clone(), own / term_docs.len().max(1) as f64)
997                })
998                .collect()
999        })
1000        .collect();
1001
1002    let mut out: Vec<TermCluster> = clusters
1003        .iter()
1004        .cloned()
1005        .enumerate()
1006        .map(|(ci, (members, docs_in))| {
1007            // members are already in salience order, since `terms` was sorted
1008            let mut member_terms: Vec<String> = members.iter().map(|m| terms[*m].0.clone()).collect();
1009
1010            // cohesion: mean pairwise similarity among members
1011            let mut sum = 0.0;
1012            let mut pairs = 0usize;
1013            for a in 0..members.len() {
1014                for b in (a + 1)..members.len() {
1015                    sum += cosine(&terms[members[a]].1, &terms[members[b]].1);
1016                    pairs += 1;
1017                }
1018            }
1019            let cohesion = if pairs == 0 { 1.0 } else { sum / pairs as f64 };
1020
1021            // label by the most distinctive term: the cluster's own documents against the whole corpus
1022            let label = name_cluster(
1023                &member_terms,
1024                &tf_in_cluster,
1025                &clusters_containing,
1026                n_clusters_final,
1027                docs_in.len(),
1028                &commons,
1029                &exclusivity[ci],
1030            );
1031
1032            member_terms.retain(|t| *t != label);
1033            member_terms.insert(0, label.clone());
1034
1035            TermCluster { label, terms: member_terms, coverage: docs_in.len() as f64 / n, cohesion }
1036        })
1037        .filter(|c| !c.label.is_empty() && c.terms.len() > 1)
1038        .collect();
1039
1040    out.sort_by(|a, b| b.coverage.partial_cmp(&a.coverage).unwrap_or(std::cmp::Ordering::Equal));
1041    out
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047
1048    fn corpus() -> Vec<String> {
1049        // two clearly separate domains, stated only in prose — no field markers to read
1050        let battles = [
1051            "Morty Shade defeated Wallace Gale in a battle at the tournament venue",
1052            "Bea Strike defeated Falkner Gale in a battle at the tournament venue",
1053            "Iris Draco defeated Nessa Reef in a battle at the tournament venue",
1054            "Marnie Dusk defeated Juan Tide in a battle at the tournament venue",
1055        ];
1056        let surveys = [
1057            "The survey recorded elevation and rainfall across the habitat region",
1058            "The survey recorded elevation and temperature across the habitat region",
1059            "A survey measured rainfall and elevation within the habitat region",
1060            "A survey measured temperature and elevation within the habitat region",
1061        ];
1062        battles.iter().chain(surveys.iter()).map(|s| s.to_string()).collect()
1063    }
1064
1065    #[test]
1066    fn discovers_the_two_domains_without_any_field_markers() {
1067        let docs = corpus();
1068        let clusters = discover(&docs, 60, 2);
1069        assert_eq!(clusters.len(), 2, "{clusters:#?}");
1070
1071        let joined: Vec<String> = clusters.iter().map(|c| c.terms.join(" ")).collect();
1072        let battle_cluster = joined.iter().find(|t| t.contains("battle")).expect(&format!("{joined:?}"));
1073        let survey_cluster = joined.iter().find(|t| t.contains("survey")).expect(&format!("{joined:?}"));
1074
1075        // the domains must not be mixed together
1076        assert!(!battle_cluster.contains("survey"), "battle cluster leaked survey terms: {battle_cluster}");
1077        assert!(!survey_cluster.contains("battle"), "survey cluster leaked battle terms: {survey_cluster}");
1078        assert!(survey_cluster.contains("elevation"), "{survey_cluster}");
1079    }
1080
1081    #[test]
1082    fn labels_are_drawn_from_the_cluster_and_are_distinctive() {
1083        let docs = corpus();
1084        for c in discover(&docs, 60, 2) {
1085            assert!(c.terms.contains(&c.label), "label must be a member: {c:?}");
1086            assert_eq!(c.terms[0], c.label, "label should lead the member list");
1087            assert!(!STOP.contains(&c.label.as_str()), "label is a stopword: {c:?}");
1088            assert!(c.coverage > 0.0 && c.coverage <= 1.0, "{c:?}");
1089        }
1090    }
1091
1092    #[test]
1093    fn prose_with_no_shared_vocabulary_yields_nothing_rather_than_noise() {
1094        // no term appears in two documents, so there is no co-occurrence to discover
1095        let docs: Vec<String> = ["alpha beta", "gamma delta", "epsilon zeta"].iter().map(|s| s.to_string()).collect();
1096        assert!(discover(&docs, 40, 3).is_empty());
1097    }
1098
1099    #[test]
1100    fn is_deterministic() {
1101        let docs = corpus();
1102        let a = discover(&docs, 60, 3);
1103        let b = discover(&docs, 60, 3);
1104        assert_eq!(
1105            a.iter().map(|c| c.terms.join(",")).collect::<Vec<_>>(),
1106            b.iter().map(|c| c.terms.join(",")).collect::<Vec<_>>()
1107        );
1108    }
1109
1110    #[test]
1111    fn quantities_are_extracted_with_their_units() {
1112        let doc = "recorded at an elevation of 1082 m. The mean temperature was 28 °C and it ran 7 minutes.";
1113        let q = quantity_spans(doc);
1114        let got: Vec<(&str, &str)> = q.iter().map(|(s, e, f)| (&doc[*s..*e], f.as_str())).collect();
1115        assert!(got.contains(&("1082 m", "length_m")), "{got:?}");
1116        assert!(got.contains(&("28 °C", "temp_c")), "{got:?}");
1117        assert!(got.contains(&("7 minutes", "minutes")), "{got:?}");
1118    }
1119
1120    #[test]
1121    fn km_is_not_read_as_m() {
1122        let doc = "a range of 500 km across";
1123        let q = quantity_spans(doc);
1124        assert_eq!(q.len(), 1, "{q:?}");
1125        assert_eq!(q[0].2, "length_km");
1126        assert_eq!(&doc[q[0].0..q[0].1], "500 km");
1127    }
1128
1129    #[test]
1130    fn gazetteer_keeps_whole_mentions_not_their_parts() {
1131        let docs: Vec<String> = [
1132            "A survey in Sootopolis City recorded Aggron near the crater",
1133            "Another survey in Sootopolis City found more Aggron there",
1134            "The Indigo Invitational was held in Sootopolis City again",
1135        ].iter().map(|s| s.to_string()).collect();
1136        let g = mine_gazetteer(&docs, 2);
1137        assert!(g.contains(&"Sootopolis City".to_string()), "{g:?}");
1138        // the parts must not be promoted on their own
1139        assert!(!g.contains(&"Sootopolis".to_string()), "{g:?}");
1140        assert!(!g.contains(&"City".to_string()), "{g:?}");
1141        // longest-first ordering so matching prefers the full mention
1142        assert!(g.iter().all(|x| x.split_whitespace().count() >= 2), "{g:?}");
1143    }
1144
1145    #[test]
1146    fn sentence_initial_capitals_do_not_become_entities() {
1147        let docs: Vec<String> = [
1148            "The survey found nothing. The survey ended early",
1149            "The survey found nothing. The survey ended early",
1150        ].iter().map(|s| s.to_string()).collect();
1151        let g = mine_gazetteer(&docs, 2);
1152        assert!(!g.iter().any(|x| x.starts_with("The ")), "{g:?}");
1153    }
1154
1155    #[test]
1156    fn generic_words_are_not_selected_as_vocabulary() {
1157        let docs: Vec<String> = (0..4)
1158            .map(|i| format!("survey {i} recorded data within the location and the distribution of species"))
1159            .collect();
1160        let picked: Vec<String> = salient(&docs, 40).into_iter().map(|(t, _, _)| t).collect();
1161        for g in ["data", "within", "location", "distribution"] {
1162            assert!(!picked.contains(&g.to_string()), "generic term leaked: {g} in {picked:?}");
1163        }
1164        assert!(picked.contains(&"survey".to_string()) || picked.contains(&"species".to_string()), "{picked:?}");
1165    }
1166
1167    #[test]
1168    fn temporal_buckets_are_extracted_and_qualified() {
1169        let doc = "held in Q3 2026 at the venue, following the 2025 season";
1170        let t = temporal_spans(doc);
1171        let toks: Vec<&str> = t.iter().map(|(_, _, x)| x.as_str()).collect();
1172        assert!(toks.contains(&"time/2026/q3"), "{toks:?}");
1173        assert!(toks.contains(&"time/2025"), "{toks:?}");
1174        // spans must index the original text
1175        for (s, e, _) in &t {
1176            assert!(doc.get(*s..*e).is_some(), "bad span {s}..{e}");
1177        }
1178    }
1179
1180    #[test]
1181    fn a_four_digit_number_that_is_not_a_year_is_ignored() {
1182        // an elevation, not a date
1183        let t = temporal_spans("an elevation of 2369 m");
1184        assert!(t.iter().all(|(_, _, x)| x != "time/2369"), "{t:?}");
1185    }
1186
1187    #[test]
1188    fn relations_carry_direction_from_word_order() {
1189        let mentions: Vec<String> = ["Morty Shade", "Wallace Gale"].iter().map(|s| s.to_string()).collect();
1190        let r = relation_spans("Morty Shade defeated Wallace Gale at the venue", &mentions);
1191        assert_eq!(r.len(), 1, "{r:?}");
1192        assert_eq!(r[0].verb, "defeated");
1193        assert_eq!(r[0].actor, "Morty Shade");
1194        assert_eq!(r[0].target, "Wallace Gale");
1195
1196        // reversing the sentence must reverse the roles, not merely relabel them
1197        let rev = relation_spans("Wallace Gale defeated Morty Shade at the venue", &mentions);
1198        assert_eq!(rev[0].actor, "Wallace Gale");
1199        assert_eq!(rev[0].target, "Morty Shade");
1200    }
1201
1202    #[test]
1203    fn no_relation_is_invented_across_a_sentence_boundary() {
1204        let mentions: Vec<String> = ["Morty Shade", "Wallace Gale"].iter().map(|s| s.to_string()).collect();
1205        // the verb and the second mention are in different sentences
1206        let r = relation_spans("Morty Shade defeated someone. Wallace Gale watched", &mentions);
1207        assert!(r.is_empty(), "should not link across sentences: {r:?}");
1208    }
1209
1210    #[test]
1211    fn a_single_mention_yields_no_relation() {
1212        let mentions: Vec<String> = vec!["Morty Shade".to_string()];
1213        assert!(relation_spans("Morty Shade defeated everyone", &mentions).is_empty());
1214    }
1215
1216    #[test]
1217    fn a_one_off_name_can_still_be_a_relation_participant() {
1218        // neither name recurs, so neither is in the corpus gazetteer
1219        let r = relation_spans("At the tournament, Juan Tide defeated Cynthia Ward in a close battle", &[]);
1220        assert!(!r.is_empty(), "should read the relation from local names: {r:?}");
1221        let d = r.iter().find(|x| x.verb == "defeated").expect("a defeated relation");
1222        assert_eq!(d.actor, "Juan Tide");
1223        assert_eq!(d.target, "Cynthia Ward");
1224    }
1225
1226    #[test]
1227    fn local_mentions_skip_the_sentence_initial_capital() {
1228        let m = local_mentions("Juan Tide defeated Cynthia Ward");
1229        let names: Vec<&str> = m.iter().map(|(_, _, n)| n.as_str()).collect();
1230        // "Juan" opens the sentence, so the run starts at "Tide"
1231        assert!(names.iter().any(|n| n.contains("Cynthia Ward")), "{names:?}");
1232        assert!(!names.iter().any(|n| n.starts_with("Juan Tide defeated")), "{names:?}");
1233    }
1234
1235    #[test]
1236    fn local_mentions_survive_multibyte_text() {
1237        // é and ° are multi-byte; a byte-wise scan panicked here with a slice boundary error
1238        for text in [
1239            "A Pokémon named Aggron was recorded at 28 °C by Cynthia Ward",
1240            "Café Ecruteak hosted Juan Tide and Bea Strike",
1241            "28 °C — Sootopolis City",
1242        ] {
1243            let m = local_mentions(text);
1244            for (s, e, name) in &m {
1245                assert_eq!(&text[*s..*e], name, "offsets must slice cleanly");
1246            }
1247        }
1248    }
1249
1250    #[test]
1251    fn relations_survive_multibyte_text() {
1252        let r = relation_spans("At the venue, Juan Tide defeated Cynthia Ward and a Pokémon at 28 °C", &[]);
1253        assert!(r.iter().any(|x| x.actor == "Juan Tide"), "{r:?}");
1254    }
1255
1256    #[test]
1257    fn punctuation_breaks_a_capitalised_run() {
1258        // three separate names, not one seven-word name
1259        let m = local_mentions("held in Violet City, Johto, Juan Tide defeated Cynthia Ward");
1260        let names: Vec<&str> = m.iter().map(|(_, _, n)| n.as_str()).collect();
1261        assert!(names.contains(&"Violet City"), "{names:?}");
1262        assert!(names.contains(&"Juan Tide"), "{names:?}");
1263        assert!(!names.iter().any(|n| n.contains(',')), "a name must not span punctuation: {names:?}");
1264
1265        let r = relation_spans("held in Violet City, Johto, Juan Tide defeated Cynthia Ward", &[]);
1266        let d = r.iter().find(|x| x.verb == "defeated").expect("a defeated relation");
1267        assert_eq!(d.actor, "Juan Tide", "nearest clean name, not a comma-joined run");
1268        assert_eq!(d.target, "Cynthia Ward");
1269    }
1270
1271    #[test]
1272    fn a_negative_quantity_keeps_its_sign() {
1273        let doc = "the mean temperature was -7 °C that winter";
1274        let q = quantity_spans(doc);
1275        let (s, e, f) = q.first().expect("a quantity").clone();
1276        assert_eq!(f, "temp_c");
1277        assert_eq!(&doc[s..e], "-7 °C", "the sign is part of the number");
1278    }
1279
1280    #[test]
1281    fn a_hyphen_between_words_is_not_a_minus_sign() {
1282        // "11-minute" is a compound, not negative eleven
1283        let doc = "an 11-minute battle";
1284        let q = quantity_spans(doc);
1285        let (s, e, _) = q.first().expect("a quantity").clone();
1286        assert_eq!(&doc[s..e], "11-minute".split('-').next().unwrap().to_owned() + "-minute");
1287        assert!(!doc[s..e].starts_with('-'), "must not read the compound hyphen as a sign: {:?}", &doc[s..e]);
1288    }
1289
1290    #[test]
1291    fn a_range_hyphen_is_not_a_minus_sign() {
1292        let doc = "between 5-10 m of clearance";
1293        for (s, e, _) in quantity_spans(doc) {
1294            assert!(!doc[s..e].starts_with('-'), "range hyphen read as a sign: {:?}", &doc[s..e]);
1295        }
1296    }
1297
1298    #[test]
1299    fn a_term_spanning_two_domains_cannot_name_either() {
1300        // "city" occurs in every battle document AND every survey document, so it is the most frequent term in
1301        // whichever group transport puts it in — and naming by frequency alone labelled the battle category
1302        // `city/*`. A label has to be specific to its own group, or it describes the corpus instead.
1303        let docs: Vec<String> = [
1304            "Morty Shade defeated Wallace Gale in a battle at Ecruteak City",
1305            "Bea Strike defeated Falkner Gale in a battle at Ecruteak City",
1306            "Iris Draco defeated Nessa Reef in a battle at Ecruteak City",
1307            "The survey recorded elevation across the habitat near Sootopolis City",
1308            "The survey recorded rainfall across the habitat near Sootopolis City",
1309            "A survey measured elevation within the habitat near Sootopolis City",
1310        ]
1311        .iter()
1312        .map(|s| s.to_string())
1313        .collect();
1314
1315        let clusters = discover(&docs, 60, 2);
1316        assert!(!clusters.is_empty(), "the two domains should still be found");
1317        for c in &clusters {
1318            assert_ne!(c.label, "city", "a term common to both domains named one of them: {c:?}");
1319        }
1320        // it may still be a MEMBER — it genuinely co-occurs — it just cannot be the name
1321        assert!(clusters.iter().any(|c| c.terms.iter().any(|t| t == "city")), "{clusters:#?}");
1322    }
1323
1324    #[test]
1325    fn discovery_transports_every_salient_term_to_some_facet() {
1326        // Sinkhorn assigns each term to exactly one target, so nothing salient is silently dropped — which is
1327        // what the agglomerative version did when a merge fell below its linkage floor.
1328        let docs = corpus();
1329        let clusters = discover(&docs, 60, 2);
1330        let placed: usize = clusters.iter().map(|c| c.terms.len()).sum();
1331        assert!(placed >= 6, "expected the salient terms to be placed, got {placed}: {clusters:#?}");
1332    }
1333
1334    #[test]
1335    fn urls_do_not_become_vocabulary() {
1336        // `https` recurred in thousands of documents and became a discovered category. It must not survive
1337        // tokenisation, nor may the host or path fragments.
1338        let toks = tokenize("See https://odin.army.mil/WEG/Asset/d2cf and http://EN.Example.COM/x for detail");
1339        for junk in ["https", "http", "odin", "army", "example", "asset", "weg"] {
1340            assert!(!toks.iter().any(|t| t == junk), "URL fragment {junk:?} leaked into tokens: {toks:?}");
1341        }
1342        assert!(toks.contains(&"see".to_string()) && toks.contains(&"detail".to_string()), "{toks:?}");
1343    }
1344
1345    #[test]
1346    fn scrubbing_a_url_next_to_non_ascii_text_does_not_panic() {
1347        // A first version indexed a lowercased copy of the string with the original's byte offsets, and a later
1348        // one sliced a string at a non-char-boundary; both panicked on real corpora where a link abuts an
1349        // accented or CJK word. This is that case.
1350        for s in [
1351            "Aggron https://例え.jp/経路 café near Sootopolis",
1352            "Pokémon https://a.b/münchen–straße done",
1353            "https://x.y/z",
1354            "wwwnoturl actually a word",
1355            "trailing https://only.at.end",
1356        ] {
1357            let _ = scrub_urls(s);
1358            let _ = tokenize(s);
1359        }
1360        // "wwwnoturl" is not a URL (no dot right after www), so it survives as a token
1361        assert!(tokenize("wwwnoturl actually a word").contains(&"wwwnoturl".to_string()));
1362    }
1363
1364    #[test]
1365    fn the_matcher_matches_the_naive_scan_exactly() {
1366        // The Aho-Corasick prefilter is only sound if it returns EXACTLY what scanning each phrase would. It
1367        // finds substrings; word boundaries are confirmed after, so a word-boundary match — which is always a
1368        // substring match — is never missed, and a substring that is not a whole word is never kept.
1369        let docs = corpus();
1370        let gaz = mine_gazetteer(&docs, 2);
1371        let matcher = MentionMatcher::new(&gaz);
1372        for d in &docs {
1373            let naive: Vec<&String> = gaz.iter().filter(|m| contains_term(d, m)).collect();
1374            let fast = matcher.present(d);
1375            assert_eq!(naive, fast, "matcher disagreed with the naive scan on: {d}");
1376
1377            let key = |v: &[Relation]| {
1378                v.iter().map(|r| format!("{}|{}|{}", r.verb, r.actor, r.target)).collect::<Vec<_>>()
1379            };
1380            assert_eq!(
1381                key(&relation_spans_with(d, &matcher)),
1382                key(&relation_spans(d, &gaz)),
1383                "relation extraction diverged via the matcher"
1384            );
1385        }
1386
1387        // a phrase present only as a non-word-boundary substring must NOT be reported
1388        let m = MentionMatcher::new(&["cat".to_string()]);
1389        assert!(m.present("the category expanded").is_empty(), "matched inside 'category'");
1390        assert_eq!(m.present("the cat sat"), vec![&"cat".to_string()]);
1391    }
1392
1393    #[test]
1394    fn a_function_word_cannot_become_a_category() {
1395        // A corpus of these eight documents discovered `near/*`. A preposition is not a kind of thing, so it
1396        // can neither name a category nor usefully join one, and one that appears in most documents adds noise
1397        // to the co-occurrence geometry that the transport plan then has to spend mass on.
1398        let docs: Vec<String> = [
1399            "Morty Shade defeated Wallace Gale at Ecruteak City in 2025.",
1400            "Bea Strike defeated Iris Draco at Ecruteak City in 2025.",
1401            "Lance Wing defeated Karen Dusk at Ecruteak City in 2025.",
1402            "A survey recorded Aggron near Sootopolis City at 28 degrees.",
1403            "A survey recorded Salamence near Sootopolis City at 31 degrees.",
1404            "A survey recorded Metagross near Sootopolis City at 19 degrees.",
1405            "Milotic is not permitted in Series 1 play for the 2025 season.",
1406            "Registeel is not permitted in Series 1 play for the 2025 season.",
1407        ]
1408        .iter()
1409        .map(|s| s.to_string())
1410        .collect();
1411
1412        for c in discover(&docs, 60, 4) {
1413            assert!(
1414                !LOCATIVES.contains(&c.label.as_str()) && !STOP.contains(&c.label.as_str()),
1415                "a function word named a category: {c:?}"
1416            );
1417            assert!(
1418                !c.terms.iter().any(|t| LOCATIVES.contains(&t.as_str())),
1419                "a function word was clustered as a signal word: {c:?}"
1420            );
1421        }
1422    }
1423
1424    #[test]
1425    fn the_word_lists_do_not_overlap() {
1426        // Duplicated entries are harmless but mean one list is being maintained in two places.
1427        for w in LOCATIVES {
1428            assert!(!STOP.contains(w), "{w} is in both STOP and LOCATIVES");
1429            assert!(!GENERIC.contains(w), "{w} is in both GENERIC and LOCATIVES");
1430        }
1431    }
1432}