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