1use std::collections::{HashMap, HashSet};
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct TermCluster {
27 pub label: String,
29 pub terms: Vec<String>,
31 pub coverage: f64,
33 pub cohesion: f64,
35}
36
37pub 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 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 && !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
83fn 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 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
125pub 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
156const MOTIF_EPS: f32 = 0.03;
158
159pub const DISCOVER_EPS: f32 = 0.05;
162
163pub 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
188pub 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 if b[i].is_ascii_digit() && (i == 0 || !(b[i - 1] as char).is_alphanumeric()) {
209 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 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 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
265pub fn contains_term(hay: &str, needle: &str) -> bool {
267 !word_spans(hay, needle).is_empty()
268}
269
270const 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
285pub struct Relation {
286 pub verb: String,
288 pub actor: String,
290 pub target: String,
292}
293
294pub 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 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 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 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
351pub fn local_mentions(sentence: &str) -> Vec<(usize, usize, String)> {
358 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 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 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 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 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
434pub 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 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 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 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
497pub 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 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 kept.sort_by(|a, b| b.len().cmp(&a.len()).then(a.cmp(b)));
541 kept
542}
543
544pub 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
564pub 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 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
593pub 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
608pub 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 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 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
663fn 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
675pub struct TreeNode {
676 pub label: String,
678 pub height: f64,
680 pub size: usize,
682 pub children: Vec<TreeNode>,
683}
684
685pub 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 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 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 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
790pub 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 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 let (_protos, assign, _cost) = crate::text::ot::codebook(&vecs, n_clusters, DISCOVER_EPS);
827
828 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 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 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 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 let mut member_terms: Vec<String> = members.iter().map(|m| terms[*m].0.clone()).collect();
893
894 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 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 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 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 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 assert!(!g.contains(&"Sootopolis".to_string()), "{g:?}");
1024 assert!(!g.contains(&"City".to_string()), "{g:?}");
1025 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}