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 scrub_urls(s: &str) -> String {
66 fn starts_url(rest: &str) -> bool {
70 let b = rest.as_bytes();
73 (b.len() >= 8 && b[..8].eq_ignore_ascii_case(b"https://"))
74 || (b.len() >= 7 && b[..7].eq_ignore_ascii_case(b"http://"))
75 || (b.len() >= 4 && b[..4].eq_ignore_ascii_case(b"www."))
76 }
77 let mut out = String::with_capacity(s.len());
78 let mut rest = s;
79 while !rest.is_empty() {
80 if starts_url(rest) {
81 match rest.find(char::is_whitespace) {
83 Some(ws) => {
84 out.push(' ');
85 rest = &rest[ws..];
86 }
87 None => break,
88 }
89 } else {
90 let ch = rest.chars().next().unwrap();
91 out.push(ch);
92 rest = &rest[ch.len_utf8()..];
93 }
94 }
95 out
96}
97
98fn tokenize(s: &str) -> Vec<String> {
99 scrub_urls(s)
100 .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '\'')
101 .map(|w| w.trim_matches('-').to_lowercase())
102 .filter(|w| {
103 w.len() >= 3
104 && w.len() <= 28
105 && w.chars().next().map(|c| c.is_alphabetic()).unwrap_or(false)
106 && !w.chars().all(|c| c.is_ascii_digit())
108 })
109 .collect()
110}
111
112const STOP: &[&str] = &[
113 "the", "and", "for", "with", "was", "were", "this", "that", "from", "into", "are", "has", "had", "his",
114 "her", "its", "not", "but", "all", "any", "may", "can", "will", "each", "than", "then", "during",
115 "under", "over", "also", "which", "while", "their", "there", "been", "being", "who", "when", "what",
116 "how", "why", "per", "via", "such", "more", "most", "less", "other", "some", "one", "two", "three",
117 "against", "after", "before", "between", "both", "out", "off", "own", "same", "too", "very", "just",
118 "him", "she", "they", "them", "these", "those", "have", "does", "did", "doing", "would", "could",
119 "should", "must", "shall", "about", "above", "below", "again", "further", "once", "here", "only",
120 "remains", "stands", "recorded", "reported", "held", "took", "made", "including", "included",
121];
122
123fn salient_terms(docs: &[String], n_terms: usize) -> Vec<(String, HashSet<usize>)> {
131 let mut incidence: HashMap<String, HashSet<usize>> = HashMap::new();
132 let mut tf: HashMap<String, usize> = HashMap::new();
133 for (i, doc) in docs.iter().enumerate() {
134 for w in tokenize(doc) {
135 if STOP.contains(&w.as_str()) || GENERIC.contains(&w.as_str()) || LOCATIVES.contains(&w.as_str()) {
136 continue;
137 }
138 *tf.entry(w.clone()).or_default() += 1;
139 incidence.entry(w).or_default().insert(i);
140 }
141 }
142
143 let n = docs.len().max(1) as f64;
144 let min_df = 2usize;
145 let max_df = ((n * 0.85).ceil() as usize).max(min_df + 1);
146
147 let mut scored: Vec<(String, f64)> = incidence
149 .iter()
150 .filter(|(_, docs_in)| docs_in.len() >= min_df && docs_in.len() <= max_df)
151 .map(|(term, _)| (term.clone(), *tf.get(term).unwrap_or(&1) as f64))
152 .collect();
153 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0)));
154 scored.truncate(n_terms);
155
156 scored
157 .into_iter()
158 .map(|(term, _)| {
159 let docs_in = incidence.remove(&term).unwrap_or_default();
160 (term, docs_in)
161 })
162 .collect()
163}
164
165pub fn discover_motifs(docs: &[String], n_terms: usize, k: usize) -> Vec<(String, Vec<String>)> {
175 let (terms, vecs) = term_vectors(docs, n_terms);
176 if terms.len() < 2 || k == 0 {
177 return Vec::new();
178 }
179 let (_protos, assign, _cost) = crate::text::ot::codebook(&vecs, k, MOTIF_EPS);
180 let groups = assign.iter().copied().max().map_or(0, |m| m + 1);
181 let mut members: Vec<Vec<String>> = vec![Vec::new(); groups];
182 for (i, &a) in assign.iter().enumerate() {
183 members[a].push(terms[i].clone());
184 }
185 let commons = common_nouns(docs);
186 members
187 .into_iter()
188 .filter(|g| !g.is_empty())
189 .filter_map(|g| {
190 let name = g.iter().find(|t| commons.contains(*t)).or_else(|| g.first())?.clone();
191 (!name.is_empty()).then_some((name, g))
192 })
193 .collect()
194}
195
196const MOTIF_EPS: f32 = 0.03;
198
199pub const DISCOVER_EPS: f32 = 0.05;
202
203pub fn term_vectors(docs: &[String], n_terms: usize) -> (Vec<String>, Vec<Vec<f32>>) {
210 let picked = salient_terms(docs, n_terms);
211 let n = docs.len().max(1);
212 let mut names = Vec::with_capacity(picked.len());
213 let mut vecs = Vec::with_capacity(picked.len());
214 for (term, docs_in) in picked {
215 let mut v = vec![0f32; n];
216 for i in &docs_in {
217 if *i < n {
218 v[*i] = 1.0;
219 }
220 }
221 let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt() + 1e-9;
222 vecs.push(v.into_iter().map(|x| x / norm).collect());
223 names.push(term);
224 }
225 (names, vecs)
226}
227
228pub fn quantity_spans(doc: &str) -> Vec<(usize, usize, String)> {
235 const UNITS: &[(&str, &str)] = &[
236 ("mm", "length_mm"), ("cm", "length_cm"), ("km", "length_km"), ("m", "length_m"),
237 ("kg", "mass_kg"), ("g", "mass_g"), ("t", "mass_t"),
238 ("°c", "temp_c"), ("°f", "temp_f"), ("c", "temp_c"),
239 ("minutes", "minutes"), ("minute", "minutes"), ("min", "minutes"),
240 ("hours", "hours"), ("hour", "hours"), ("seconds", "seconds"),
241 ("mm/yr", "rainfall_mm"), ("%", "percent"),
242 ];
243 let b = doc.as_bytes();
244 let mut out = Vec::new();
245 let mut i = 0usize;
246 while i < b.len() {
247 if b[i].is_ascii_digit() && (i == 0 || !(b[i - 1] as char).is_alphanumeric()) {
249 let mut start = i;
254 if i > 0 && b[i - 1] == b'-' {
255 let before_sign = i >= 2 && !(b[i - 2] as char).is_alphanumeric() && b[i - 2] != b'-';
256 if i == 1 || before_sign {
257 start = i - 1;
258 }
259 }
260 let mut j = i;
261 while j < b.len() && (b[j].is_ascii_digit() || b[j] == b'.' || b[j] == b',') {
262 j += 1;
263 }
264 let num_end = j;
265 let mut k = j;
267 while k < b.len() && (b[k] == b' ' || b[k] == b'-') {
268 k += 1;
269 }
270 if k < b.len() && doc.is_char_boundary(k) {
271 let rest = &doc[k..];
272 let unit_len = rest
273 .char_indices()
274 .take_while(|(_, c)| c.is_alphabetic() || *c == '°' || *c == '%' || *c == '/')
275 .map(|(bi, c)| bi + c.len_utf8())
276 .last()
277 .unwrap_or(0);
278 if unit_len > 0 {
279 let unit = rest[..unit_len].to_lowercase();
280 let mut best: Option<(&str, usize)> = None;
282 for (u, field) in UNITS {
283 if unit == *u && best.map(|(_, l)| u.len() > l).unwrap_or(true) {
284 best = Some((field, u.len()));
285 }
286 }
287 if let Some((field, ulen)) = best {
288 let end = k + ulen;
289 if doc.is_char_boundary(start) && doc.is_char_boundary(end) {
290 out.push((start, end, field.to_string()));
291 i = end;
292 continue;
293 }
294 }
295 }
296 }
297 i = num_end.max(i + 1);
298 continue;
299 }
300 i += 1;
301 }
302 out
303}
304
305pub fn contains_term(hay: &str, needle: &str) -> bool {
307 !word_spans(hay, needle).is_empty()
308}
309
310pub fn word_set(doc: &str) -> std::collections::HashSet<String> {
312 doc.split(|c: char| !c.is_alphanumeric())
313 .filter(|w| !w.is_empty())
314 .map(|w| w.chars().flat_map(|c| c.to_lowercase()).collect())
315 .collect()
316}
317
318pub struct MentionMatcher {
330 ac: aho_corasick::AhoCorasick,
331 surfaces: Vec<String>,
332}
333
334impl MentionMatcher {
335 pub fn new(mentions: &[String]) -> MentionMatcher {
336 let surfaces: Vec<String> = mentions.to_vec();
337 let lowered: Vec<String> = surfaces.iter().map(|s| s.to_lowercase()).collect();
340 let ac = aho_corasick::AhoCorasick::builder()
341 .match_kind(aho_corasick::MatchKind::Standard)
342 .build(&lowered)
343 .expect("gazetteer automaton");
344 MentionMatcher { ac, surfaces }
345 }
346
347 pub fn present<'a>(&'a self, doc: &str) -> Vec<&'a String> {
349 let lower = doc.to_lowercase();
350 let mut seen = vec![false; self.surfaces.len()];
351 for m in self.ac.find_overlapping_iter(&lower) {
354 let id = m.pattern().as_usize();
355 if seen[id] {
356 continue;
357 }
358 if contains_term(doc, &self.surfaces[id]) {
359 seen[id] = true;
360 }
361 }
362 self.surfaces.iter().enumerate().filter(|(i, _)| seen[*i]).map(|(_, s)| s).collect()
363 }
364}
365
366pub fn mentions_present<'a>(doc: &str, mentions: &'a [String]) -> Vec<&'a String> {
369 let matcher = MentionMatcher::new(mentions);
371 let present: std::collections::HashSet<&str> =
372 matcher.present(doc).into_iter().map(|s| s.as_str()).collect();
373 mentions.iter().filter(|m| present.contains(m.as_str())).collect()
374}
375
376const REL_VERBS: &[(&str, &str)] = &[
380 ("defeated", "defeated"), ("beat", "defeated"), ("faced", "faced"), ("met", "faced"),
381 ("documented", "documented"), ("recorded", "recorded"), ("measured", "measured"),
382 ("observed", "observed"), ("found", "observed"), ("held", "held_at"), ("hosted", "held_at"),
383 ("used", "used"), ("led", "used"), ("answered", "answered_with"), ("commanded", "commanded"),
384 ("permitted", "permitted"), ("banned", "banned"), ("restricted", "restricted"),
385 ("competed", "competed_in"), ("entered", "competed_in"), ("won", "won"), ("secured", "secured"),
386 ("contributes", "contributes_to"), ("supplies", "supplies"), ("operates", "operates"),
387];
388
389#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
391pub struct Relation {
392 pub verb: String,
394 pub actor: String,
396 pub target: String,
398}
399
400pub fn relation_spans(doc: &str, mentions: &[String]) -> Vec<Relation> {
413 relation_spans_with(doc, &MentionMatcher::new(mentions))
414}
415
416pub fn relation_spans_with(doc: &str, matcher: &MentionMatcher) -> Vec<Relation> {
421 let mut out: Vec<Relation> = Vec::new();
422 for sentence in doc.split(['.', ';', '!', '?', '\n']) {
423 if sentence.trim().is_empty() {
424 continue;
425 }
426 let mut found: Vec<(usize, usize, String)> = Vec::new();
430 for m in matcher.present(sentence) {
431 for (s, e) in word_spans(sentence, m) {
432 if !found.iter().any(|(fs, fe, _)| s >= *fs && e <= *fe) {
433 found.push((s, e, m.clone()));
434 }
435 }
436 }
437 for (s, e, name) in local_mentions(sentence) {
441 if !found.iter().any(|(fs, fe, _)| s < *fe && e > *fs) {
442 found.push((s, e, name));
443 }
444 }
445 if found.len() < 2 {
446 continue;
447 }
448 found.sort_by_key(|(s, _, _)| *s);
449
450 for (raw, canon) in REL_VERBS {
451 for (vs, ve) in word_spans(sentence, raw) {
452 let actor = found.iter().filter(|(_, e, _)| *e <= vs).next_back();
454 let target = found.iter().find(|(s, _, _)| *s >= ve);
455 if let (Some((_, _, a)), Some((_, _, t))) = (actor, target) {
456 if a != t {
457 out.push(Relation { verb: canon.to_string(), actor: a.clone(), target: t.clone() });
458 }
459 }
460 }
461 }
462 }
463 out.dedup_by(|a, b| a.verb == b.verb && a.actor == b.actor && a.target == b.target);
464 out
465}
466
467pub fn local_mentions(sentence: &str) -> Vec<(usize, usize, String)> {
474 let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '-';
478
479 let mut words: Vec<(usize, usize, &str)> = Vec::new();
480 let mut cur: Option<usize> = None;
481 for (i, c) in sentence.char_indices() {
482 if is_word(c) {
483 if cur.is_none() {
484 cur = Some(i);
485 }
486 } else if let Some(st) = cur.take() {
487 words.push((st, i, &sentence[st..i]));
488 }
489 }
490 if let Some(st) = cur {
491 words.push((st, sentence.len(), &sentence[st..]));
492 }
493
494 let mut runs: Vec<(usize, usize)> = Vec::new();
497 let mut run: Option<(usize, usize)> = None;
498 let mut prev_end: Option<usize> = None;
499 for (wi, (st, en, w)) in words.iter().enumerate() {
500 let capped = w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) && w.chars().count() > 1;
501 let punctuated = prev_end
505 .map(|pe| sentence[pe..*st].chars().any(|c| !c.is_whitespace()))
506 .unwrap_or(false);
507 if punctuated {
508 if let Some(r) = run.take() {
509 runs.push(r);
510 }
511 }
512 if capped {
513 run = Some(match run {
514 Some((rs, _)) => (rs, wi),
515 None => (wi, wi),
516 });
517 } else if let Some(r) = run.take() {
518 runs.push(r);
519 }
520 prev_end = Some(*en);
521 }
522 if let Some(r) = run {
523 runs.push(r);
524 }
525
526 const DETERMINERS: &[&str] = &[
531 "the", "a", "an", "this", "that", "these", "those", "their", "its", "his", "her", "our", "your", "my",
532 "it", "they", "we", "he", "she", "there", "then", "when", "where", "what", "which", "who",
533 ];
534 let mut out: Vec<(usize, usize, String)> = Vec::new();
535 for (first, last) in runs {
536 let mut first = first;
537 if first == 0 && DETERMINERS.contains(&words[0].2.to_lowercase().as_str()) {
538 first += 1;
540 }
541 if first > last {
542 continue;
543 }
544 let (rs, re) = (words[first].0, words[last].1);
545 out.push((rs, re, sentence[rs..re].to_string()));
546 }
547 out
548}
549
550pub fn temporal_spans(doc: &str) -> Vec<(usize, usize, String)> {
557 let b = doc.as_bytes();
558 let mut out: Vec<(usize, usize, String)> = Vec::new();
559
560 let mut i = 0usize;
562 while i + 1 < b.len() {
563 if (b[i] == b'Q' || b[i] == b'q') && b[i + 1].is_ascii_digit() {
564 let q = (b[i + 1] - b'0') as u32;
565 let starts_word = i == 0 || !(b[i - 1] as char).is_alphanumeric();
566 if (1..=4).contains(&q) && starts_word {
567 let end = i + 2;
568 let tail = &doc[end..].trim_start();
570 let year: Option<u32> = tail
571 .split(|c: char| !c.is_ascii_digit())
572 .next()
573 .filter(|t| t.len() == 4)
574 .and_then(|t| t.parse().ok())
575 .filter(|y| (1900..2200).contains(y));
576 let token = match year {
577 Some(y) => format!("time/{y}/q{q}"),
578 None => format!("time/q{q}"),
579 };
580 if doc.is_char_boundary(i) && doc.is_char_boundary(end) {
581 out.push((i, end, token));
582 }
583 i = end;
584 continue;
585 }
586 }
587 i += 1;
588 }
589
590 let mut j = 0usize;
592 while j + 3 < b.len() {
593 if b[j].is_ascii_digit() {
594 let before_ok = j == 0 || !(b[j - 1] as char).is_alphanumeric();
595 let end = j + 4;
596 let after_ok = end >= b.len() || !(b[end] as char).is_alphanumeric();
597 if before_ok && after_ok && b[j..end].iter().all(|c| c.is_ascii_digit()) {
598 if let Ok(y) = doc[j..end].parse::<u32>() {
599 if (1900..2200).contains(&y) && !out.iter().any(|(s, e, _)| j >= *s && end <= *e) {
600 out.push((j, end, format!("time/{y}")));
601 }
602 }
603 j = end;
604 continue;
605 }
606 }
607 j += 1;
608 }
609 out.sort_by_key(|(s, _, _)| *s);
610 out
611}
612
613pub fn mine_gazetteer(docs: &[String], min_count: usize) -> Vec<String> {
623 let mut counts: HashMap<String, usize> = HashMap::new();
624 for doc in docs {
625 for sentence in doc.split(['.', '\n', ';', '!', '?']) {
626 let words: Vec<&str> = sentence.split_whitespace().collect();
627 let mut run: Vec<&str> = Vec::new();
628 let mut first = true;
629 for w in words {
630 let clean = w.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '-');
631 let cap = clean
632 .chars()
633 .next()
634 .map(|c| c.is_uppercase())
635 .unwrap_or(false)
636 && clean.len() > 1;
637 if cap && !(first && run.is_empty()) {
639 run.push(clean);
640 } else {
641 if run.len() >= 2 {
642 *counts.entry(run.join(" ")).or_default() += 1;
643 }
644 run.clear();
645 }
646 first = false;
647 }
648 if run.len() >= 2 {
649 *counts.entry(run.join(" ")).or_default() += 1;
650 }
651 }
652 }
653 let mut kept: Vec<String> =
654 counts.into_iter().filter(|(_, n)| *n >= min_count).map(|(s, _)| s).collect();
655 kept.sort_by(|a, b| b.len().cmp(&a.len()).then(a.cmp(b)));
657 kept
658}
659
660pub const LOCATIVES: &[&str] = &[
667 "near", "nearby", "across", "along", "around", "through", "throughout", "toward", "towards",
668 "beside", "behind", "beyond", "upon", "onto", "inside", "outside", "amid", "among", "amongst",
669 "beneath", "underneath", "alongside", "opposite", "past", "since", "until", "till", "unto",
670];
671
672pub const GENERIC: &[&str] = &[
673 "within", "presence", "data", "contributes", "distribution", "environmental", "understanding",
674 "preferences", "observation", "site", "location", "conditions", "period", "mean", "documented",
675 "recorded", "measured", "reported", "described", "including", "various", "distinctive", "populations",
676 "ecological", "information", "details", "features", "aspects", "elements", "factors", "values",
677 "results", "analysis", "summary", "overview", "context", "purposes", "requirements",
678];
679
680pub fn word_spans(hay: &str, needle: &str) -> Vec<(usize, usize)> {
684 let mut out = Vec::new();
685 if needle.is_empty() {
686 return out;
687 }
688 let lower = hay.to_lowercase();
689 let pat = needle.to_lowercase();
690 let bytes = lower.as_bytes();
691 let mut from = 0usize;
692 while let Some(rel) = lower[from..].find(&pat) {
693 let s = from + rel;
694 let e = s + pat.len();
695 let before_ok = s == 0 || !(bytes[s - 1] as char).is_alphanumeric();
696 let after_ok = e >= bytes.len() || !(bytes[e] as char).is_alphanumeric();
697 if before_ok && after_ok && hay.is_char_boundary(s) && hay.is_char_boundary(e) {
699 out.push((s, e));
700 }
701 from = s + pat.len().max(1);
702 if from >= lower.len() {
703 break;
704 }
705 }
706 out
707}
708
709pub fn salient(docs: &[String], n_terms: usize) -> Vec<(String, f64, usize)> {
712 let picked = salient_terms(docs, n_terms);
713 let n = docs.len().max(1) as f64;
714 picked
715 .into_iter()
716 .map(|(term, docs_in)| {
717 let d = docs_in.len();
718 let idf = ((n + 1.0) / (d as f64 + 1.0)).ln() + 1.0;
719 (term, idf, d)
720 })
721 .collect()
722}
723
724pub fn name_cluster(
736 members: &[String],
737 tf_in_cluster: &HashMap<String, usize>,
738 clusters_containing: &HashMap<String, usize>,
739 n_clusters: usize,
740 cluster_size: usize,
741 commons: &HashSet<String>,
742 exclusivity: &HashMap<String, f64>,
743) -> String {
744 let n = n_clusters.max(1) as f64;
745 let size = cluster_size.max(1) as f64;
746
747 const MIN_EXCLUSIVITY: f64 = 0.8;
754 let confined: Vec<String> = members
755 .iter()
756 .filter(|t| exclusivity.get(*t).copied().unwrap_or(1.0) >= MIN_EXCLUSIVITY)
757 .cloned()
758 .collect();
759 let pool: &[String] = if confined.is_empty() { members } else { &confined };
760
761 let mut scored: Vec<(String, f64)> = pool
762 .iter()
763 .map(|t| {
764 let tf = *tf_in_cluster.get(t).unwrap_or(&1) as f64;
765 let dfc = *clusters_containing.get(t).unwrap_or(&1) as f64;
766 let idf = ((n + 1.0) / (dfc + 1.0)).ln() + 1.0;
767 let coverage = (tf / size).min(1.0);
771 let common_bonus = if commons.contains(t) { 1.3 } else { 1.0 };
772 (t.clone(), coverage * common_bonus + 0.12 * idf)
773 })
774 .collect();
775 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0)));
776 scored.first().map(|(t, _)| t.clone()).unwrap_or_default()
777}
778
779fn cosine(a: &HashSet<usize>, b: &HashSet<usize>) -> f64 {
781 if a.is_empty() || b.is_empty() {
782 return 0.0;
783 }
784 let inter = a.intersection(b).count() as f64;
785 inter / ((a.len() as f64).sqrt() * (b.len() as f64).sqrt())
786}
787
788#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
791pub struct TreeNode {
792 pub label: String,
794 pub height: f64,
796 pub size: usize,
798 pub children: Vec<TreeNode>,
799}
800
801pub fn discover_hierarchy(docs: &[String], n_terms: usize, n_clusters: usize) -> Option<TreeNode> {
807 let terms = salient_terms(docs, n_terms.clamp(2, 400));
808 if terms.len() < 2 {
809 return None;
810 }
811 let cut = n_clusters.clamp(1, terms.len());
812 let commons = common_nouns(docs);
813
814 let mut active: Vec<(Vec<usize>, HashSet<usize>, TreeNode)> = terms
816 .iter()
817 .enumerate()
818 .map(|(i, (t, d))| {
819 (vec![i], d.clone(), TreeNode { label: t.clone(), height: 0.0, size: 1, children: Vec::new() })
820 })
821 .collect();
822
823 let avg_linkage = |a: &[usize], b: &[usize]| -> f64 {
824 let mut sum = 0.0;
825 for x in a {
826 for y in b {
827 sum += cosine(&terms[*x].1, &terms[*y].1);
828 }
829 }
830 sum / (a.len() * b.len()) as f64
831 };
832
833 let mut labelled = false;
835 while active.len() > 1 {
836 if active.len() == cut && !labelled {
837 labelled = true;
838 let n_c = active.len();
839 let mut tf: HashMap<String, usize> = HashMap::new();
840 let mut containing: HashMap<String, usize> = HashMap::new();
841 for (members, docs_in, _) in &active {
842 let mut seen = HashSet::new();
843 for m in members {
844 let t = &terms[*m].0;
845 let c = docs_in.iter().filter_map(|i| docs.get(*i)).filter(|d| !word_spans(d, t).is_empty()).count();
846 *tf.entry(t.clone()).or_default() += c.max(1);
847 seen.insert(t.clone());
848 }
849 for t in seen {
850 *containing.entry(t).or_default() += 1;
851 }
852 }
853 let excl: Vec<HashMap<String, f64>> = (0..active.len())
855 .map(|ci| {
856 let others: HashSet<usize> = active
857 .iter()
858 .enumerate()
859 .filter(|(cj, _)| *cj != ci)
860 .flat_map(|(_, (_, docs_j, _))| docs_j.iter().copied())
861 .collect();
862 active[ci]
863 .0
864 .iter()
865 .map(|m| {
866 let (term, term_docs) = &terms[*m];
867 let own = term_docs.iter().filter(|d| !others.contains(d)).count() as f64;
868 (term.clone(), own / term_docs.len().max(1) as f64)
869 })
870 .collect()
871 })
872 .collect();
873 for (ci, (members, docs_in, node)) in active.iter_mut().enumerate() {
874 let names: Vec<String> = members.iter().map(|m| terms[*m].0.clone()).collect();
875 node.label =
876 name_cluster(&names, &tf, &containing, n_c, docs_in.len(), &commons, &excl[ci]);
877 }
878 }
879
880 let mut best: Option<(usize, usize, f64)> = None;
881 for i in 0..active.len() {
882 for j in (i + 1)..active.len() {
883 let s = avg_linkage(&active[i].0, &active[j].0);
884 if best.map(|(_, _, bs)| s > bs).unwrap_or(true) {
885 best = Some((i, j, s));
886 }
887 }
888 }
889 let Some((i, j, sim)) = best else { break };
890 let (mj, dj, nj) = active.remove(j);
891 let (_, _, ni) = &active[i];
892 let merged = TreeNode {
893 label: String::new(),
894 height: sim,
895 size: ni.size + nj.size,
896 children: vec![active[i].2.clone(), nj],
897 };
898 active[i].0.extend(mj);
899 active[i].1.extend(dj);
900 active[i].2 = merged;
901 }
902
903 active.into_iter().next().map(|(_, _, n)| n)
904}
905
906pub fn discover(docs: &[String], n_terms: usize, n_clusters: usize) -> Vec<TermCluster> {
911 let terms = salient_terms(docs, n_terms.clamp(2, 400));
912 if terms.len() < 2 || n_clusters == 0 {
913 return Vec::new();
914 }
915
916 let n_docs = docs.len().max(1);
920 let vecs: Vec<Vec<f32>> = terms
921 .iter()
922 .map(|(_, docs_in)| {
923 let mut v = vec![0f32; n_docs];
924 for i in docs_in {
925 if *i < n_docs {
926 v[*i] = 1.0;
927 }
928 }
929 let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt() + 1e-9;
930 v.into_iter().map(|x| x / norm).collect()
931 })
932 .collect();
933
934 let (_protos, assign, _cost) = crate::text::ot::codebook(&vecs, n_clusters, DISCOVER_EPS);
943
944 let k = assign.iter().copied().max().map_or(0, |m| m + 1);
946 let mut clusters: Vec<(Vec<usize>, HashSet<usize>)> = vec![(Vec::new(), HashSet::new()); k];
947 for (i, &a) in assign.iter().enumerate() {
948 clusters[a].0.push(i);
949 clusters[a].1.extend(terms[i].1.iter().copied());
950 }
951 clusters.retain(|(members, _)| !members.is_empty());
952
953 let commons = common_nouns(docs);
954 let n = docs.len().max(1) as f64;
955
956 let n_clusters_final = clusters.len();
960 let mut tf_in_cluster: HashMap<String, usize> = HashMap::new();
961 let mut clusters_containing: HashMap<String, usize> = HashMap::new();
962 for (members, docs_in) in &clusters {
963 let mut seen: HashSet<String> = HashSet::new();
964 for m in members {
965 let term = &terms[*m].0;
966 let count = docs_in
968 .iter()
969 .filter_map(|i| docs.get(*i))
970 .filter(|d| word_spans(d, term).len() > 0)
971 .count();
972 *tf_in_cluster.entry(term.clone()).or_default() += count.max(1);
973 seen.insert(term.clone());
974 }
975 for t in seen {
976 *clusters_containing.entry(t).or_default() += 1;
977 }
978 }
979 let exclusivity: Vec<HashMap<String, f64>> = (0..clusters.len())
983 .map(|ci| {
984 let others: HashSet<usize> = clusters
985 .iter()
986 .enumerate()
987 .filter(|(cj, _)| *cj != ci)
988 .flat_map(|(_, (_, docs_j))| docs_j.iter().copied())
989 .collect();
990 clusters[ci]
991 .0
992 .iter()
993 .map(|m| {
994 let (term, term_docs) = &terms[*m];
995 let own = term_docs.iter().filter(|d| !others.contains(d)).count() as f64;
996 (term.clone(), own / term_docs.len().max(1) as f64)
997 })
998 .collect()
999 })
1000 .collect();
1001
1002 let mut out: Vec<TermCluster> = clusters
1003 .iter()
1004 .cloned()
1005 .enumerate()
1006 .map(|(ci, (members, docs_in))| {
1007 let mut member_terms: Vec<String> = members.iter().map(|m| terms[*m].0.clone()).collect();
1009
1010 let mut sum = 0.0;
1012 let mut pairs = 0usize;
1013 for a in 0..members.len() {
1014 for b in (a + 1)..members.len() {
1015 sum += cosine(&terms[members[a]].1, &terms[members[b]].1);
1016 pairs += 1;
1017 }
1018 }
1019 let cohesion = if pairs == 0 { 1.0 } else { sum / pairs as f64 };
1020
1021 let label = name_cluster(
1023 &member_terms,
1024 &tf_in_cluster,
1025 &clusters_containing,
1026 n_clusters_final,
1027 docs_in.len(),
1028 &commons,
1029 &exclusivity[ci],
1030 );
1031
1032 member_terms.retain(|t| *t != label);
1033 member_terms.insert(0, label.clone());
1034
1035 TermCluster { label, terms: member_terms, coverage: docs_in.len() as f64 / n, cohesion }
1036 })
1037 .filter(|c| !c.label.is_empty() && c.terms.len() > 1)
1038 .collect();
1039
1040 out.sort_by(|a, b| b.coverage.partial_cmp(&a.coverage).unwrap_or(std::cmp::Ordering::Equal));
1041 out
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047
1048 fn corpus() -> Vec<String> {
1049 let battles = [
1051 "Morty Shade defeated Wallace Gale in a battle at the tournament venue",
1052 "Bea Strike defeated Falkner Gale in a battle at the tournament venue",
1053 "Iris Draco defeated Nessa Reef in a battle at the tournament venue",
1054 "Marnie Dusk defeated Juan Tide in a battle at the tournament venue",
1055 ];
1056 let surveys = [
1057 "The survey recorded elevation and rainfall across the habitat region",
1058 "The survey recorded elevation and temperature across the habitat region",
1059 "A survey measured rainfall and elevation within the habitat region",
1060 "A survey measured temperature and elevation within the habitat region",
1061 ];
1062 battles.iter().chain(surveys.iter()).map(|s| s.to_string()).collect()
1063 }
1064
1065 #[test]
1066 fn discovers_the_two_domains_without_any_field_markers() {
1067 let docs = corpus();
1068 let clusters = discover(&docs, 60, 2);
1069 assert_eq!(clusters.len(), 2, "{clusters:#?}");
1070
1071 let joined: Vec<String> = clusters.iter().map(|c| c.terms.join(" ")).collect();
1072 let battle_cluster = joined.iter().find(|t| t.contains("battle")).expect(&format!("{joined:?}"));
1073 let survey_cluster = joined.iter().find(|t| t.contains("survey")).expect(&format!("{joined:?}"));
1074
1075 assert!(!battle_cluster.contains("survey"), "battle cluster leaked survey terms: {battle_cluster}");
1077 assert!(!survey_cluster.contains("battle"), "survey cluster leaked battle terms: {survey_cluster}");
1078 assert!(survey_cluster.contains("elevation"), "{survey_cluster}");
1079 }
1080
1081 #[test]
1082 fn labels_are_drawn_from_the_cluster_and_are_distinctive() {
1083 let docs = corpus();
1084 for c in discover(&docs, 60, 2) {
1085 assert!(c.terms.contains(&c.label), "label must be a member: {c:?}");
1086 assert_eq!(c.terms[0], c.label, "label should lead the member list");
1087 assert!(!STOP.contains(&c.label.as_str()), "label is a stopword: {c:?}");
1088 assert!(c.coverage > 0.0 && c.coverage <= 1.0, "{c:?}");
1089 }
1090 }
1091
1092 #[test]
1093 fn prose_with_no_shared_vocabulary_yields_nothing_rather_than_noise() {
1094 let docs: Vec<String> = ["alpha beta", "gamma delta", "epsilon zeta"].iter().map(|s| s.to_string()).collect();
1096 assert!(discover(&docs, 40, 3).is_empty());
1097 }
1098
1099 #[test]
1100 fn is_deterministic() {
1101 let docs = corpus();
1102 let a = discover(&docs, 60, 3);
1103 let b = discover(&docs, 60, 3);
1104 assert_eq!(
1105 a.iter().map(|c| c.terms.join(",")).collect::<Vec<_>>(),
1106 b.iter().map(|c| c.terms.join(",")).collect::<Vec<_>>()
1107 );
1108 }
1109
1110 #[test]
1111 fn quantities_are_extracted_with_their_units() {
1112 let doc = "recorded at an elevation of 1082 m. The mean temperature was 28 °C and it ran 7 minutes.";
1113 let q = quantity_spans(doc);
1114 let got: Vec<(&str, &str)> = q.iter().map(|(s, e, f)| (&doc[*s..*e], f.as_str())).collect();
1115 assert!(got.contains(&("1082 m", "length_m")), "{got:?}");
1116 assert!(got.contains(&("28 °C", "temp_c")), "{got:?}");
1117 assert!(got.contains(&("7 minutes", "minutes")), "{got:?}");
1118 }
1119
1120 #[test]
1121 fn km_is_not_read_as_m() {
1122 let doc = "a range of 500 km across";
1123 let q = quantity_spans(doc);
1124 assert_eq!(q.len(), 1, "{q:?}");
1125 assert_eq!(q[0].2, "length_km");
1126 assert_eq!(&doc[q[0].0..q[0].1], "500 km");
1127 }
1128
1129 #[test]
1130 fn gazetteer_keeps_whole_mentions_not_their_parts() {
1131 let docs: Vec<String> = [
1132 "A survey in Sootopolis City recorded Aggron near the crater",
1133 "Another survey in Sootopolis City found more Aggron there",
1134 "The Indigo Invitational was held in Sootopolis City again",
1135 ].iter().map(|s| s.to_string()).collect();
1136 let g = mine_gazetteer(&docs, 2);
1137 assert!(g.contains(&"Sootopolis City".to_string()), "{g:?}");
1138 assert!(!g.contains(&"Sootopolis".to_string()), "{g:?}");
1140 assert!(!g.contains(&"City".to_string()), "{g:?}");
1141 assert!(g.iter().all(|x| x.split_whitespace().count() >= 2), "{g:?}");
1143 }
1144
1145 #[test]
1146 fn sentence_initial_capitals_do_not_become_entities() {
1147 let docs: Vec<String> = [
1148 "The survey found nothing. The survey ended early",
1149 "The survey found nothing. The survey ended early",
1150 ].iter().map(|s| s.to_string()).collect();
1151 let g = mine_gazetteer(&docs, 2);
1152 assert!(!g.iter().any(|x| x.starts_with("The ")), "{g:?}");
1153 }
1154
1155 #[test]
1156 fn generic_words_are_not_selected_as_vocabulary() {
1157 let docs: Vec<String> = (0..4)
1158 .map(|i| format!("survey {i} recorded data within the location and the distribution of species"))
1159 .collect();
1160 let picked: Vec<String> = salient(&docs, 40).into_iter().map(|(t, _, _)| t).collect();
1161 for g in ["data", "within", "location", "distribution"] {
1162 assert!(!picked.contains(&g.to_string()), "generic term leaked: {g} in {picked:?}");
1163 }
1164 assert!(picked.contains(&"survey".to_string()) || picked.contains(&"species".to_string()), "{picked:?}");
1165 }
1166
1167 #[test]
1168 fn temporal_buckets_are_extracted_and_qualified() {
1169 let doc = "held in Q3 2026 at the venue, following the 2025 season";
1170 let t = temporal_spans(doc);
1171 let toks: Vec<&str> = t.iter().map(|(_, _, x)| x.as_str()).collect();
1172 assert!(toks.contains(&"time/2026/q3"), "{toks:?}");
1173 assert!(toks.contains(&"time/2025"), "{toks:?}");
1174 for (s, e, _) in &t {
1176 assert!(doc.get(*s..*e).is_some(), "bad span {s}..{e}");
1177 }
1178 }
1179
1180 #[test]
1181 fn a_four_digit_number_that_is_not_a_year_is_ignored() {
1182 let t = temporal_spans("an elevation of 2369 m");
1184 assert!(t.iter().all(|(_, _, x)| x != "time/2369"), "{t:?}");
1185 }
1186
1187 #[test]
1188 fn relations_carry_direction_from_word_order() {
1189 let mentions: Vec<String> = ["Morty Shade", "Wallace Gale"].iter().map(|s| s.to_string()).collect();
1190 let r = relation_spans("Morty Shade defeated Wallace Gale at the venue", &mentions);
1191 assert_eq!(r.len(), 1, "{r:?}");
1192 assert_eq!(r[0].verb, "defeated");
1193 assert_eq!(r[0].actor, "Morty Shade");
1194 assert_eq!(r[0].target, "Wallace Gale");
1195
1196 let rev = relation_spans("Wallace Gale defeated Morty Shade at the venue", &mentions);
1198 assert_eq!(rev[0].actor, "Wallace Gale");
1199 assert_eq!(rev[0].target, "Morty Shade");
1200 }
1201
1202 #[test]
1203 fn no_relation_is_invented_across_a_sentence_boundary() {
1204 let mentions: Vec<String> = ["Morty Shade", "Wallace Gale"].iter().map(|s| s.to_string()).collect();
1205 let r = relation_spans("Morty Shade defeated someone. Wallace Gale watched", &mentions);
1207 assert!(r.is_empty(), "should not link across sentences: {r:?}");
1208 }
1209
1210 #[test]
1211 fn a_single_mention_yields_no_relation() {
1212 let mentions: Vec<String> = vec!["Morty Shade".to_string()];
1213 assert!(relation_spans("Morty Shade defeated everyone", &mentions).is_empty());
1214 }
1215
1216 #[test]
1217 fn a_one_off_name_can_still_be_a_relation_participant() {
1218 let r = relation_spans("At the tournament, Juan Tide defeated Cynthia Ward in a close battle", &[]);
1220 assert!(!r.is_empty(), "should read the relation from local names: {r:?}");
1221 let d = r.iter().find(|x| x.verb == "defeated").expect("a defeated relation");
1222 assert_eq!(d.actor, "Juan Tide");
1223 assert_eq!(d.target, "Cynthia Ward");
1224 }
1225
1226 #[test]
1227 fn local_mentions_skip_the_sentence_initial_capital() {
1228 let m = local_mentions("Juan Tide defeated Cynthia Ward");
1229 let names: Vec<&str> = m.iter().map(|(_, _, n)| n.as_str()).collect();
1230 assert!(names.iter().any(|n| n.contains("Cynthia Ward")), "{names:?}");
1232 assert!(!names.iter().any(|n| n.starts_with("Juan Tide defeated")), "{names:?}");
1233 }
1234
1235 #[test]
1236 fn local_mentions_survive_multibyte_text() {
1237 for text in [
1239 "A Pokémon named Aggron was recorded at 28 °C by Cynthia Ward",
1240 "Café Ecruteak hosted Juan Tide and Bea Strike",
1241 "28 °C — Sootopolis City",
1242 ] {
1243 let m = local_mentions(text);
1244 for (s, e, name) in &m {
1245 assert_eq!(&text[*s..*e], name, "offsets must slice cleanly");
1246 }
1247 }
1248 }
1249
1250 #[test]
1251 fn relations_survive_multibyte_text() {
1252 let r = relation_spans("At the venue, Juan Tide defeated Cynthia Ward and a Pokémon at 28 °C", &[]);
1253 assert!(r.iter().any(|x| x.actor == "Juan Tide"), "{r:?}");
1254 }
1255
1256 #[test]
1257 fn punctuation_breaks_a_capitalised_run() {
1258 let m = local_mentions("held in Violet City, Johto, Juan Tide defeated Cynthia Ward");
1260 let names: Vec<&str> = m.iter().map(|(_, _, n)| n.as_str()).collect();
1261 assert!(names.contains(&"Violet City"), "{names:?}");
1262 assert!(names.contains(&"Juan Tide"), "{names:?}");
1263 assert!(!names.iter().any(|n| n.contains(',')), "a name must not span punctuation: {names:?}");
1264
1265 let r = relation_spans("held in Violet City, Johto, Juan Tide defeated Cynthia Ward", &[]);
1266 let d = r.iter().find(|x| x.verb == "defeated").expect("a defeated relation");
1267 assert_eq!(d.actor, "Juan Tide", "nearest clean name, not a comma-joined run");
1268 assert_eq!(d.target, "Cynthia Ward");
1269 }
1270
1271 #[test]
1272 fn a_negative_quantity_keeps_its_sign() {
1273 let doc = "the mean temperature was -7 °C that winter";
1274 let q = quantity_spans(doc);
1275 let (s, e, f) = q.first().expect("a quantity").clone();
1276 assert_eq!(f, "temp_c");
1277 assert_eq!(&doc[s..e], "-7 °C", "the sign is part of the number");
1278 }
1279
1280 #[test]
1281 fn a_hyphen_between_words_is_not_a_minus_sign() {
1282 let doc = "an 11-minute battle";
1284 let q = quantity_spans(doc);
1285 let (s, e, _) = q.first().expect("a quantity").clone();
1286 assert_eq!(&doc[s..e], "11-minute".split('-').next().unwrap().to_owned() + "-minute");
1287 assert!(!doc[s..e].starts_with('-'), "must not read the compound hyphen as a sign: {:?}", &doc[s..e]);
1288 }
1289
1290 #[test]
1291 fn a_range_hyphen_is_not_a_minus_sign() {
1292 let doc = "between 5-10 m of clearance";
1293 for (s, e, _) in quantity_spans(doc) {
1294 assert!(!doc[s..e].starts_with('-'), "range hyphen read as a sign: {:?}", &doc[s..e]);
1295 }
1296 }
1297
1298 #[test]
1299 fn a_term_spanning_two_domains_cannot_name_either() {
1300 let docs: Vec<String> = [
1304 "Morty Shade defeated Wallace Gale in a battle at Ecruteak City",
1305 "Bea Strike defeated Falkner Gale in a battle at Ecruteak City",
1306 "Iris Draco defeated Nessa Reef in a battle at Ecruteak City",
1307 "The survey recorded elevation across the habitat near Sootopolis City",
1308 "The survey recorded rainfall across the habitat near Sootopolis City",
1309 "A survey measured elevation within the habitat near Sootopolis City",
1310 ]
1311 .iter()
1312 .map(|s| s.to_string())
1313 .collect();
1314
1315 let clusters = discover(&docs, 60, 2);
1316 assert!(!clusters.is_empty(), "the two domains should still be found");
1317 for c in &clusters {
1318 assert_ne!(c.label, "city", "a term common to both domains named one of them: {c:?}");
1319 }
1320 assert!(clusters.iter().any(|c| c.terms.iter().any(|t| t == "city")), "{clusters:#?}");
1322 }
1323
1324 #[test]
1325 fn discovery_transports_every_salient_term_to_some_facet() {
1326 let docs = corpus();
1329 let clusters = discover(&docs, 60, 2);
1330 let placed: usize = clusters.iter().map(|c| c.terms.len()).sum();
1331 assert!(placed >= 6, "expected the salient terms to be placed, got {placed}: {clusters:#?}");
1332 }
1333
1334 #[test]
1335 fn urls_do_not_become_vocabulary() {
1336 let toks = tokenize("See https://odin.army.mil/WEG/Asset/d2cf and http://EN.Example.COM/x for detail");
1339 for junk in ["https", "http", "odin", "army", "example", "asset", "weg"] {
1340 assert!(!toks.iter().any(|t| t == junk), "URL fragment {junk:?} leaked into tokens: {toks:?}");
1341 }
1342 assert!(toks.contains(&"see".to_string()) && toks.contains(&"detail".to_string()), "{toks:?}");
1343 }
1344
1345 #[test]
1346 fn scrubbing_a_url_next_to_non_ascii_text_does_not_panic() {
1347 for s in [
1351 "Aggron https://例え.jp/経路 café near Sootopolis",
1352 "Pokémon https://a.b/münchen–straße done",
1353 "https://x.y/z",
1354 "wwwnoturl actually a word",
1355 "trailing https://only.at.end",
1356 ] {
1357 let _ = scrub_urls(s);
1358 let _ = tokenize(s);
1359 }
1360 assert!(tokenize("wwwnoturl actually a word").contains(&"wwwnoturl".to_string()));
1362 }
1363
1364 #[test]
1365 fn the_matcher_matches_the_naive_scan_exactly() {
1366 let docs = corpus();
1370 let gaz = mine_gazetteer(&docs, 2);
1371 let matcher = MentionMatcher::new(&gaz);
1372 for d in &docs {
1373 let naive: Vec<&String> = gaz.iter().filter(|m| contains_term(d, m)).collect();
1374 let fast = matcher.present(d);
1375 assert_eq!(naive, fast, "matcher disagreed with the naive scan on: {d}");
1376
1377 let key = |v: &[Relation]| {
1378 v.iter().map(|r| format!("{}|{}|{}", r.verb, r.actor, r.target)).collect::<Vec<_>>()
1379 };
1380 assert_eq!(
1381 key(&relation_spans_with(d, &matcher)),
1382 key(&relation_spans(d, &gaz)),
1383 "relation extraction diverged via the matcher"
1384 );
1385 }
1386
1387 let m = MentionMatcher::new(&["cat".to_string()]);
1389 assert!(m.present("the category expanded").is_empty(), "matched inside 'category'");
1390 assert_eq!(m.present("the cat sat"), vec![&"cat".to_string()]);
1391 }
1392
1393 #[test]
1394 fn a_function_word_cannot_become_a_category() {
1395 let docs: Vec<String> = [
1399 "Morty Shade defeated Wallace Gale at Ecruteak City in 2025.",
1400 "Bea Strike defeated Iris Draco at Ecruteak City in 2025.",
1401 "Lance Wing defeated Karen Dusk at Ecruteak City in 2025.",
1402 "A survey recorded Aggron near Sootopolis City at 28 degrees.",
1403 "A survey recorded Salamence near Sootopolis City at 31 degrees.",
1404 "A survey recorded Metagross near Sootopolis City at 19 degrees.",
1405 "Milotic is not permitted in Series 1 play for the 2025 season.",
1406 "Registeel is not permitted in Series 1 play for the 2025 season.",
1407 ]
1408 .iter()
1409 .map(|s| s.to_string())
1410 .collect();
1411
1412 for c in discover(&docs, 60, 4) {
1413 assert!(
1414 !LOCATIVES.contains(&c.label.as_str()) && !STOP.contains(&c.label.as_str()),
1415 "a function word named a category: {c:?}"
1416 );
1417 assert!(
1418 !c.terms.iter().any(|t| LOCATIVES.contains(&t.as_str())),
1419 "a function word was clustered as a signal word: {c:?}"
1420 );
1421 }
1422 }
1423
1424 #[test]
1425 fn the_word_lists_do_not_overlap() {
1426 for w in LOCATIVES {
1428 assert!(!STOP.contains(w), "{w} is in both STOP and LOCATIVES");
1429 assert!(!GENERIC.contains(w), "{w} is in both GENERIC and LOCATIVES");
1430 }
1431 }
1432}