1use crate::cli::TriageArgs;
2use crate::error::{AppError, AppResult};
3use crate::output::{self, Meta};
4use crate::store;
5use crate::{ItemStatus, ListItem};
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::PathBuf;
10
11#[derive(Debug, Serialize, Deserialize)]
12pub struct TriageData {
13 pub clusters: Vec<TriageCluster>,
14 pub count: usize,
15 pub scanned: usize,
16}
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct TriageCluster {
20 pub count: usize,
21 pub occurrences: usize,
22 pub ids: Vec<String>,
23 pub tags: Vec<String>,
24 pub text: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub origin: Option<crate::Origin>,
27}
28
29#[derive(Clone)]
30pub(crate) struct Candidate {
31 pub(crate) item: ListItem,
32 pub(crate) timestamp: Timestamp,
33 pub(crate) tags: BTreeSet<String>,
34 pub(crate) normalized_title: String,
35 pub(crate) tokens: BTreeSet<String>,
36}
37
38pub(crate) struct CorpusFrequencies {
39 token_counts: BTreeMap<String, usize>,
40 tag_counts: BTreeMap<String, usize>,
41 candidate_count: usize,
42}
43
44pub(crate) struct ChronicCluster {
45 pub(crate) members: Vec<Candidate>,
46 pub(crate) member_occurrences: Vec<usize>,
47 displayed_occurrences: usize,
48}
49
50pub(crate) struct ChronicAnalysis {
51 pub(crate) clusters: Vec<ChronicCluster>,
52 pub(crate) scanned: usize,
53}
54
55struct OrderedCluster {
56 data: ChronicCluster,
57 oldest_timestamp: Timestamp,
58 first_id: String,
59}
60
61pub(crate) struct CandidateIndex {
88 by_title: BTreeMap<String, Vec<usize>>,
89 by_tag: BTreeMap<String, BitSet>,
90 untagged: BitSet,
91 by_token: BTreeMap<String, BitSet>,
92 by_token_count: BTreeMap<usize, BitSet>,
93 all: BitSet,
94}
95
96impl CandidateIndex {
97 pub(crate) fn new(candidates: &[Candidate], frequencies: &CorpusFrequencies) -> Self {
98 let words = candidates.len().div_ceil(64);
99 let mut by_title = BTreeMap::new();
100 let mut by_tag = BTreeMap::new();
101 let mut untagged = BitSet::empty(words);
102 let mut by_token = BTreeMap::new();
103 let mut by_token_count = BTreeMap::new();
104
105 for (index, candidate) in candidates.iter().enumerate() {
106 if !candidate.normalized_title.is_empty() {
107 by_title
108 .entry(candidate.normalized_title.clone())
109 .or_insert_with(Vec::new)
110 .push(index);
111 }
112 if candidate.tags.is_empty() {
113 untagged.set(index);
114 } else {
115 for tag in &candidate.tags {
116 if frequencies.is_shared_tag(tag) {
117 by_tag
118 .entry(tag.clone())
119 .or_insert_with(|| BitSet::empty(words))
120 .set(index);
121 }
122 }
123 }
124 if !candidate.tokens.is_empty() {
125 by_token_count
126 .entry(candidate.tokens.len())
127 .or_insert_with(|| BitSet::empty(words))
128 .set(index);
129 for token in &candidate.tokens {
130 if frequencies.is_shared(token) {
131 by_token
132 .entry(token.clone())
133 .or_insert_with(|| BitSet::empty(words))
134 .set(index);
135 }
136 }
137 }
138 }
139
140 Self {
141 by_title,
142 by_tag,
143 untagged,
144 by_token,
145 by_token_count,
146 all: BitSet::full(candidates.len()),
147 }
148 }
149}
150
151pub(crate) struct BitSet {
152 words: Vec<u64>,
153}
154
155impl BitSet {
156 fn empty(words: usize) -> Self {
157 Self {
158 words: vec![0; words],
159 }
160 }
161
162 fn full(bits: usize) -> Self {
163 let words = bits.div_ceil(64);
164 let mut result = Self {
165 words: vec![u64::MAX; words],
166 };
167 if let Some(last) = result.words.last_mut()
168 && !bits.is_multiple_of(64)
169 {
170 *last = (1_u64 << (bits % 64)) - 1;
171 }
172 result
173 }
174
175 fn clear_from(&mut self, word: usize) {
176 self.words[word..].fill(0);
177 }
178
179 fn set(&mut self, index: usize) {
180 self.words[index / 64] |= 1_u64 << (index % 64);
181 }
182
183 fn copy_from(&mut self, word: usize, source: &Self) {
184 self.words[word..].copy_from_slice(&source.words[word..]);
185 }
186
187 fn or_assign(&mut self, word: usize, source: &Self) {
188 for (target, source) in self.words[word..].iter_mut().zip(&source.words[word..]) {
189 *target |= source;
190 }
191 }
192
193 fn and_assign(&mut self, word: usize, source: &Self) {
194 for (target, source) in self.words[word..].iter_mut().zip(&source.words[word..]) {
195 *target &= source;
196 }
197 }
198
199 pub(crate) fn indices_from(&self, floor: usize) -> impl Iterator<Item = usize> + '_ {
203 let first_word = floor / 64;
204 let first_mask = u64::MAX << (floor % 64);
205 self.words
206 .iter()
207 .enumerate()
208 .skip(first_word)
209 .flat_map(move |(word_index, word)| {
210 let mut remaining = if word_index == first_word {
211 *word & first_mask
212 } else {
213 *word
214 };
215 std::iter::from_fn(move || {
216 if remaining == 0 {
217 return None;
218 }
219 let bit = remaining.trailing_zeros() as usize;
220 remaining &= remaining - 1;
221 Some(word_index * 64 + bit)
222 })
223 })
224 }
225}
226
227struct BitSlicedCounter {
231 words: usize,
232 planes: Vec<Vec<u64>>,
233}
234
235impl BitSlicedCounter {
236 fn new(words: usize) -> Self {
237 Self {
238 words,
239 planes: Vec::new(),
240 }
241 }
242
243 fn reset(&mut self, word: usize, maximum: usize) {
244 let required_planes = if maximum == 0 {
245 0
246 } else {
247 maximum.ilog2() as usize + 1
248 };
249 self.planes
250 .resize_with(required_planes, || vec![0; self.words]);
251 for plane in &mut self.planes {
252 plane[word..].fill(0);
253 }
254 }
255
256 fn add(&mut self, word: usize, source: &BitSet) {
257 for word_index in word..self.words {
258 let mut carry = source.words[word_index];
259 for plane in &mut self.planes {
260 let next_carry = plane[word_index] & carry;
261 plane[word_index] ^= carry;
262 carry = next_carry;
263 }
264 }
265 }
266
267 fn add_at_least(&self, word: usize, threshold: usize, allowed: &BitSet, target: &mut BitSet) {
268 debug_assert!(threshold > 0);
269 for word_index in word..self.words {
270 let mut equal = allowed.words[word_index];
271 let mut greater = 0;
272 for (bit, plane) in self.planes.iter().enumerate().rev() {
273 if threshold & (1 << bit) == 0 {
274 greater |= equal & plane[word_index];
275 equal &= !plane[word_index];
276 } else {
277 equal &= plane[word_index];
278 }
279 }
280 target.words[word_index] |= greater | equal;
281 }
282 }
283}
284
285pub(crate) struct CandidateScratch {
286 tag_pool: BitSet,
287 matches: BitSet,
288 overlap: BitSlicedCounter,
289 rare: BitSlicedCounter,
290}
291
292impl CandidateScratch {
293 pub(crate) fn new(candidate_count: usize) -> Self {
294 let words = candidate_count.div_ceil(64);
295 Self {
296 tag_pool: BitSet::empty(words),
297 matches: BitSet::empty(words),
298 overlap: BitSlicedCounter::new(words),
299 rare: BitSlicedCounter::new(words),
300 }
301 }
302
303 pub(crate) fn matching_candidates<'a>(
311 &'a mut self,
312 representative: &Candidate,
313 index: &CandidateIndex,
314 frequencies: &CorpusFrequencies,
315 floor: usize,
316 ) -> &'a BitSet {
317 let word = floor / 64;
318 self.matches.clear_from(word);
319 if self.score_tokens(representative, index, frequencies, word) {
320 self.tag_pool.clear_from(word);
321 if representative.tags.is_empty() {
322 self.tag_pool.copy_from(word, &index.untagged);
323 } else {
324 for tag in &representative.tags {
325 if let Some(pool) = index.by_tag.get(tag) {
326 self.tag_pool.or_assign(word, pool);
327 }
328 }
329 }
330 self.matches.and_assign(word, &self.tag_pool);
331 }
332
333 if !representative.normalized_title.is_empty()
334 && let Some(candidates) = index.by_title.get(&representative.normalized_title)
335 {
336 for &candidate in candidates {
337 self.matches.set(candidate);
338 }
339 }
340 &self.matches
341 }
342
343 fn score_tokens(
356 &mut self,
357 representative: &Candidate,
358 index: &CandidateIndex,
359 frequencies: &CorpusFrequencies,
360 word: usize,
361 ) -> bool {
362 if representative.tokens.is_empty() {
363 return false;
364 }
365 let reachable = representative
366 .tokens
367 .iter()
368 .filter(|token| index.by_token.contains_key(*token))
369 .count();
370 let mut scored = false;
371
372 let lowest = index
375 .by_token_count
376 .keys()
377 .next()
378 .map(|count| overlap_threshold(representative.tokens.len(), *count));
379 if lowest.is_some_and(|threshold| threshold <= reachable) {
380 self.overlap.reset(word, reachable);
383 for token in &representative.tokens {
384 if let Some(posting) = index.by_token.get(token) {
387 self.overlap.add(word, posting);
388 }
389 }
390 for (token_count, candidates) in &index.by_token_count {
391 let threshold = overlap_threshold(representative.tokens.len(), *token_count);
392 if threshold > reachable {
393 break;
394 }
395 self.overlap
396 .add_at_least(word, threshold, candidates, &mut self.matches);
397 }
398 scored = true;
399 }
400
401 let rare_reachable = representative
403 .tokens
404 .iter()
405 .filter(|token| frequencies.is_rare(token) && index.by_token.contains_key(*token))
406 .count();
407 if rare_reachable >= MIN_RARE_SHARED_TOKENS {
408 self.rare.reset(word, rare_reachable);
409 for token in representative
410 .tokens
411 .iter()
412 .filter(|token| frequencies.is_rare(token))
413 {
414 if let Some(posting) = index.by_token.get(token) {
415 self.rare.add(word, posting);
416 }
417 }
418 self.rare
419 .add_at_least(word, MIN_RARE_SHARED_TOKENS, &index.all, &mut self.matches);
420 scored = true;
421 }
422
423 scored
424 }
425}
426
427fn overlap_threshold(representative_tokens: usize, candidate_tokens: usize) -> usize {
428 (representative_tokens.min(candidate_tokens) * MIN_OVERLAP_NUMERATOR)
429 .div_ceil(MIN_OVERLAP_DENOMINATOR)
430}
431
432pub fn run(args: TriageArgs, file: Option<PathBuf>, pretty: bool) -> AppResult<i32> {
433 if args.min_count < 2 {
434 return Err(AppError::invalid_argument(
435 "--min-count must be at least 2",
436 "Pass --min-count 2 or greater.",
437 ));
438 }
439
440 let resolved = store::discover(file)?;
441 let store::LoadedFold {
442 items, warnings, ..
443 } = store::load_folded(&resolved)?;
444
445 let data = triage(items, args.min_count);
446 let exit = i32::from(!data.clusters.is_empty());
447 let mut meta = Meta::new();
448 meta.file = Some(resolved.path.to_string_lossy().into_owned());
449 meta.warnings = warnings;
450 output::write_success(data, pretty, meta)
451 .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
452 Ok(exit)
453}
454
455pub(crate) fn triage(items: Vec<ListItem>, min_count: usize) -> TriageData {
456 let analysis = chronic_clusters(items, min_count);
457 let clusters: Vec<_> = analysis.clusters.iter().map(materialize_cluster).collect();
458
459 TriageData {
460 count: clusters.len(),
461 clusters,
462 scanned: analysis.scanned,
463 }
464}
465
466pub(crate) fn chronic_clusters(items: Vec<ListItem>, min_count: usize) -> ChronicAnalysis {
467 let mut candidates: Vec<_> = items
468 .into_iter()
469 .filter(is_open_cut)
470 .map(|item| {
471 let normalized_title = normalized_title(&item.text);
472 Candidate {
473 timestamp: item
474 .ts
475 .parse()
476 .expect("folded items have valid RFC3339 timestamps"),
477 tags: item.tags.iter().cloned().collect(),
478 tokens: scoring_tokens(&normalized_title),
479 normalized_title,
480 item,
481 }
482 })
483 .collect();
484 candidates.sort_by(candidate_order);
485
486 let scanned = candidates.len();
487 let frequencies = corpus_frequencies(candidates.iter());
488 let mut title_occurrences = BTreeMap::new();
492 for candidate in &candidates {
493 *title_occurrences
494 .entry(candidate.normalized_title.clone())
495 .or_insert(0) += 1;
496 }
497
498 let candidate_index = CandidateIndex::new(&candidates, &frequencies);
499 let mut scratch = CandidateScratch::new(scanned);
500 let mut claimed = vec![false; scanned];
501 let mut clusters = Vec::new();
502 for representative in 0..scanned {
503 if claimed[representative] {
504 continue;
505 }
506
507 let mut members = vec![representative];
514 for candidate in scratch
515 .matching_candidates(
516 &candidates[representative],
517 &candidate_index,
518 &frequencies,
519 representative + 1,
520 )
521 .indices_from(representative + 1)
522 {
523 if !claimed[candidate]
524 && linked(
525 &candidates[representative],
526 &candidates[candidate],
527 &frequencies,
528 )
529 {
530 members.push(candidate);
531 }
532 }
533
534 if members.len() >= min_count {
538 for &member in &members {
539 claimed[member] = true;
540 }
541 clusters.push(ordered_cluster(&candidates, members, &title_occurrences));
542 }
543 }
544 clusters.sort_by(|left, right| {
545 right
546 .data
547 .members
548 .len()
549 .cmp(&left.data.members.len())
550 .then_with(|| left.oldest_timestamp.cmp(&right.oldest_timestamp))
551 .then_with(|| left.first_id.cmp(&right.first_id))
552 });
553 ChronicAnalysis {
554 clusters: clusters.into_iter().map(|cluster| cluster.data).collect(),
555 scanned,
556 }
557}
558
559fn is_open_cut(item: &ListItem) -> bool {
560 item.kind == "cut" && item.status == ItemStatus::Open
561}
562
563pub(crate) fn candidate_order(left: &Candidate, right: &Candidate) -> std::cmp::Ordering {
564 left.timestamp
565 .cmp(&right.timestamp)
566 .then_with(|| left.item.id.cmp(&right.item.id))
567}
568
569pub(crate) fn normalized_title(text: &str) -> String {
570 text.to_lowercase()
571 .chars()
572 .map(|character| {
573 if character.is_alphanumeric() {
574 character
575 } else {
576 ' '
577 }
578 })
579 .collect::<String>()
580 .split_whitespace()
581 .collect::<Vec<_>>()
582 .join(" ")
583}
584
585const STOPWORDS: &[&str] = &[
608 "about",
609 "above",
610 "after",
611 "again",
612 "against",
613 "all",
614 "and",
615 "any",
616 "are",
617 "aren",
618 "because",
619 "been",
620 "before",
621 "being",
622 "below",
623 "between",
624 "both",
625 "but",
626 "can",
627 "cannot",
628 "could",
629 "couldn",
630 "did",
631 "didn",
632 "does",
633 "doesn",
634 "doing",
635 "don",
636 "down",
637 "during",
638 "each",
639 "few",
640 "for",
641 "from",
642 "further",
643 "had",
644 "hadn",
645 "has",
646 "hasn",
647 "have",
648 "haven",
649 "having",
650 "her",
651 "here",
652 "hers",
653 "herself",
654 "him",
655 "himself",
656 "his",
657 "how",
658 "into",
659 "isn",
660 "its",
661 "itself",
662 "let",
663 "more",
664 "most",
665 "mustn",
666 "myself",
667 "need",
668 "nor",
669 "not",
670 "off",
671 "once",
672 "one",
673 "only",
674 "other",
675 "ought",
676 "our",
677 "ours",
678 "ourselves",
679 "out",
680 "over",
681 "own",
682 "same",
683 "shan",
684 "she",
685 "should",
686 "shouldn",
687 "some",
688 "such",
689 "than",
690 "that",
691 "the",
692 "their",
693 "theirs",
694 "them",
695 "themselves",
696 "then",
697 "there",
698 "these",
699 "they",
700 "this",
701 "those",
702 "through",
703 "too",
704 "under",
705 "until",
706 "use",
707 "uses",
708 "very",
709 "was",
710 "wasn",
711 "were",
712 "weren",
713 "what",
714 "when",
715 "where",
716 "which",
717 "while",
718 "who",
719 "whom",
720 "why",
721 "with",
722 "won",
723 "would",
724 "wouldn",
725 "you",
726 "your",
727 "yours",
728 "yourself",
729 "yourselves",
730];
731const MIN_OVERLAP_NUMERATOR: usize = 4;
732const MIN_OVERLAP_DENOMINATOR: usize = 5;
733const MIN_RARE_SHARED_TOKENS: usize = 3;
734
735pub(crate) fn scoring_tokens(normalized_title: &str) -> BTreeSet<String> {
736 normalized_title
737 .split_whitespace()
738 .filter(|token| token.chars().count() > 2 && STOPWORDS.binary_search(token).is_err())
739 .map(str::to_owned)
740 .collect()
741}
742
743pub(crate) fn corpus_frequencies<'a>(
749 candidates: impl IntoIterator<Item = &'a Candidate>,
750) -> CorpusFrequencies {
751 let mut token_counts = BTreeMap::new();
752 let mut tag_counts = BTreeMap::new();
753 let mut candidate_count = 0;
754 for candidate in candidates {
755 candidate_count += 1;
756 for token in &candidate.tokens {
757 *token_counts.entry(token.clone()).or_insert(0) += 1;
758 }
759 for tag in &candidate.tags {
760 *tag_counts.entry(tag.clone()).or_insert(0) += 1;
761 }
762 }
763 CorpusFrequencies {
764 token_counts,
765 tag_counts,
766 candidate_count,
767 }
768}
769
770impl CorpusFrequencies {
771 fn is_shared(&self, token: &str) -> bool {
774 self.token_counts
775 .get(token)
776 .copied()
777 .expect("tokens being indexed were counted")
778 > 1
779 }
780
781 fn is_shared_tag(&self, tag: &str) -> bool {
784 self.tag_counts
785 .get(tag)
786 .copied()
787 .expect("tags being indexed were counted")
788 > 1
789 }
790
791 fn is_rare(&self, token: &str) -> bool {
792 let rare_limit = self.candidate_count.div_ceil(16).max(2);
793 self.token_counts
794 .get(token)
795 .copied()
796 .expect("tokens being scored were counted")
797 <= rare_limit
798 }
799}
800
801pub(crate) fn linked(left: &Candidate, right: &Candidate, frequencies: &CorpusFrequencies) -> bool {
802 if !left.normalized_title.is_empty() && left.normalized_title == right.normalized_title {
806 return true;
807 }
808
809 let matching_tags =
814 (left.tags.is_empty() && right.tags.is_empty()) || !left.tags.is_disjoint(&right.tags);
815 matching_tags && similar_enough(&left.tokens, &right.tokens, frequencies)
816}
817
818fn similar_enough(
819 left: &BTreeSet<String>,
820 right: &BTreeSet<String>,
821 frequencies: &CorpusFrequencies,
822) -> bool {
823 if left.is_empty() || right.is_empty() {
824 return false;
825 }
826 let shared = left.intersection(right).count();
827 let shorter = left.len().min(right.len());
828 if shared * MIN_OVERLAP_DENOMINATOR >= shorter * MIN_OVERLAP_NUMERATOR {
831 return true;
832 }
833
834 left.intersection(right)
835 .filter(|token| frequencies.is_rare(token))
836 .count()
837 >= MIN_RARE_SHARED_TOKENS
838}
839
840fn ordered_cluster(
841 candidates: &[Candidate],
842 mut members: Vec<usize>,
843 title_occurrences: &BTreeMap<String, usize>,
844) -> OrderedCluster {
845 members.sort_by(|left, right| candidate_order(&candidates[*left], &candidates[*right]));
846 let representative = &candidates[members[0]];
847 let member_occurrences: Vec<_> = members
848 .iter()
849 .map(|index| {
850 title_occurrences
851 .get(&candidates[*index].normalized_title)
852 .copied()
853 .expect("every candidate title is counted")
854 })
855 .collect();
856
857 OrderedCluster {
858 oldest_timestamp: representative.timestamp,
859 first_id: representative.item.id.clone(),
860 data: ChronicCluster {
861 displayed_occurrences: *member_occurrences
866 .last()
867 .expect("chronic clusters have members"),
868 member_occurrences,
869 members: members
870 .into_iter()
871 .map(|index| candidates[index].clone())
872 .collect(),
873 },
874 }
875}
876
877fn materialize_cluster(cluster: &ChronicCluster) -> TriageCluster {
878 let latest = cluster
879 .members
880 .last()
881 .expect("chronic clusters have members");
882 let tags: BTreeSet<_> = cluster
883 .members
884 .iter()
885 .flat_map(|candidate| candidate.tags.iter().cloned())
886 .collect();
887
888 TriageCluster {
889 count: cluster.members.len(),
890 occurrences: cluster.displayed_occurrences,
893 ids: cluster
894 .members
895 .iter()
896 .map(|candidate| candidate.item.id.clone())
897 .collect(),
898 tags: tags.into_iter().collect(),
899 text: latest.item.text.clone(),
900 origin: latest.item.origin.clone(),
901 }
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907 use crate::Impact;
908
909 #[test]
910 fn stopwords_are_sorted_and_unique_for_binary_search() {
911 assert!(
912 STOPWORDS.windows(2).all(|pair| pair[0] < pair[1]),
913 "STOPWORDS must be sorted and deduplicated: binary_search depends on it"
914 );
915 }
916
917 #[test]
935 fn stopwords_are_tokens_the_tokenizer_can_emit() {
936 for word in STOPWORDS {
937 assert_eq!(
938 normalized_title(word),
939 *word,
940 "`{word}` is not what the tokenizer produces, so no token can ever match it"
941 );
942 assert!(
943 word.chars().count() > 2,
944 "`{word}` is below the length filter `scoring_tokens` applies before the lookup"
945 );
946 }
947 }
948
949 fn candidate(index: usize, text: &str, tags: &[&str]) -> Candidate {
950 let timestamp = format!("2026-08-18T00:00:{index:02}.000Z");
951 let normalized_title = normalized_title(text);
952 let item = ListItem {
953 kind: "cut".into(),
954 id: format!("bl_{index:020x}"),
955 ts: timestamp.clone(),
956 agent: "test".into(),
957 text: text.into(),
958 tags: tags.iter().map(|tag| (*tag).into()).collect(),
959 impact: Some(Impact::Low),
960 cwd: ".".into(),
961 origin: None,
962 evidence: None,
963 status: ItemStatus::Open,
964 resolution: None,
965 };
966 Candidate {
967 timestamp: timestamp.parse().unwrap(),
968 tags: item.tags.iter().cloned().collect(),
969 tokens: scoring_tokens(&normalized_title),
970 normalized_title,
971 item,
972 }
973 }
974
975 #[test]
976 fn candidate_index_matches_the_direct_link_rule_for_mixed_pools() {
977 let mut candidates = vec![
982 candidate(0, "Exact title is strongest", &["alpha"]),
983 candidate(1, "exact-title is strongest!", &["beta"]),
984 candidate(
985 2,
986 "cache endpoint returns alpha beta gamma",
987 &["ops", "api"],
988 ),
989 candidate(3, "cache endpoint returns alpha beta delta", &["ops"]),
990 candidate(4, "cache endpoint returns alpha beta delta", &["billing"]),
991 candidate(5, "red green blue purple orange", &["rare"]),
992 candidate(6, "red green blue violet magenta", &["rare"]),
993 candidate(7, "untagged data source cache fail", &[]),
994 candidate(8, "untagged data cache source failing", &[]),
995 candidate(9, "same words different isolated tag", &["isolated"]),
996 candidate(10, "same words different other tag", &["other"]),
997 candidate(11, "the and for", &["ops"]),
998 candidate(12, "this with need", &["ops"]),
999 candidate(13, "!!!", &["ops"]),
1000 candidate(14, "???", &["ops"]),
1001 candidate(15, "wholly unshared vocabulary xyzzy", &["ops"]),
1005 ];
1006 candidates.sort_by(candidate_order);
1007 let frequencies = corpus_frequencies(candidates.iter());
1008 let index = CandidateIndex::new(&candidates, &frequencies);
1009 let mut scratch = CandidateScratch::new(candidates.len());
1010
1011 for (representative_index, representative) in candidates.iter().enumerate() {
1012 let actual: Vec<_> = scratch
1013 .matching_candidates(representative, &index, &frequencies, 0)
1014 .indices_from(0)
1015 .collect();
1016 let expected: Vec<_> = candidates
1017 .iter()
1018 .enumerate()
1019 .filter_map(|(candidate_index, candidate)| {
1020 linked(representative, candidate, &frequencies).then_some(candidate_index)
1021 })
1022 .collect();
1023 assert_eq!(
1024 actual, expected,
1025 "candidate index changed the direct-link set for representative {representative_index}"
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn candidate_index_matches_the_direct_link_rule_for_external_representatives() {
1032 let mut open = vec![
1038 candidate(
1039 0,
1040 "cache endpoint returns alpha beta gamma",
1041 &["shared-once"],
1042 ),
1043 candidate(1, "unrelated wording nothing repeats here", &["solo"]),
1044 candidate(2, "cache endpoint returns alpha beta gamma", &["ops"]),
1045 ];
1046 open.sort_by(candidate_order);
1047 let anchors = [
1048 candidate(
1049 10,
1050 "cache endpoint returns alpha beta delta",
1051 &["shared-once"],
1052 ),
1053 candidate(
1054 11,
1055 "cache endpoint returns alpha beta delta",
1056 &["absent-tag"],
1057 ),
1058 ];
1059
1060 let frequencies = corpus_frequencies(open.iter().chain(anchors.iter()));
1061 let index = CandidateIndex::new(&open, &frequencies);
1062 let mut scratch = CandidateScratch::new(open.len());
1063 for (anchor_index, anchor) in anchors.iter().enumerate() {
1064 let actual: Vec<_> = scratch
1065 .matching_candidates(anchor, &index, &frequencies, 0)
1066 .indices_from(0)
1067 .collect();
1068 let expected: Vec<_> = open
1069 .iter()
1070 .enumerate()
1071 .filter_map(|(candidate_index, candidate)| {
1072 linked(anchor, candidate, &frequencies).then_some(candidate_index)
1073 })
1074 .collect();
1075 assert_eq!(
1076 actual, expected,
1077 "anchor {anchor_index} diverged from the pair rule"
1078 );
1079 }
1080 assert!(
1081 !expected_is_empty(&open, &anchors[0], &frequencies),
1082 "the anchor sharing a once-carried tag must link, or this test proves nothing"
1083 );
1084 }
1085
1086 fn expected_is_empty(
1087 open: &[Candidate],
1088 anchor: &Candidate,
1089 frequencies: &CorpusFrequencies,
1090 ) -> bool {
1091 !open
1092 .iter()
1093 .any(|candidate| linked(anchor, candidate, frequencies))
1094 }
1095
1096 struct Rng(u64);
1099
1100 impl Rng {
1101 fn next(&mut self) -> u64 {
1102 self.0 ^= self.0 << 13;
1103 self.0 ^= self.0 >> 7;
1104 self.0 ^= self.0 << 17;
1105 self.0
1106 }
1107
1108 fn below(&mut self, limit: usize) -> usize {
1109 (self.next() % limit as u64) as usize
1110 }
1111 }
1112
1113 #[test]
1114 fn candidate_index_matches_the_direct_link_rule_across_random_corpora() {
1115 const SHARED_WORDS: &[&str] = &[
1120 "cache", "endpoint", "timeout", "retry", "parse", "lock", "schema", "digest",
1121 ];
1122 const TAGS: &[&str] = &["ops", "api", "billing", "store"];
1123
1124 for seed in 1..=400_u64 {
1125 let mut rng = Rng(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1);
1126 let count = 2 + rng.below(14);
1127 let mut candidates = Vec::with_capacity(count);
1128 for index in 0..count {
1129 let mut words = Vec::new();
1130 for _ in 0..1 + rng.below(5) {
1131 words.push(SHARED_WORDS[rng.below(SHARED_WORDS.len())].to_owned());
1132 }
1133 for unique in 0..rng.below(4) {
1136 words.push(format!("uniq{seed}x{index}x{unique}"));
1137 }
1138 let tag_count = rng.below(3);
1139 let tags: Vec<_> = (0..tag_count)
1140 .map(|_| TAGS[rng.below(TAGS.len())])
1141 .collect();
1142 candidates.push(candidate(index, &words.join(" "), &tags));
1143 }
1144 candidates.sort_by(candidate_order);
1145
1146 let frequencies = corpus_frequencies(candidates.iter());
1147 let index = CandidateIndex::new(&candidates, &frequencies);
1148 let mut scratch = CandidateScratch::new(candidates.len());
1149 for (representative_index, representative) in candidates.iter().enumerate() {
1150 let actual: Vec<_> = scratch
1151 .matching_candidates(representative, &index, &frequencies, 0)
1152 .indices_from(0)
1153 .collect();
1154 let expected: Vec<_> = candidates
1155 .iter()
1156 .enumerate()
1157 .filter_map(|(candidate_index, candidate)| {
1158 linked(representative, candidate, &frequencies).then_some(candidate_index)
1159 })
1160 .collect();
1161 assert_eq!(
1162 actual, expected,
1163 "seed {seed} representative {representative_index} diverged from the pair rule"
1164 );
1165 }
1166 }
1167 }
1168}