Skip to main content

blotter/commands/
triage.rs

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
61/// Candidate pools preserve the triage relation while avoiding a scan of every
62/// later cut for each representative. Exact titles bypass tags, while the
63/// normal scoring path can only use candidates in a shared-tag or untagged
64/// pool. Token bitsets then identify the candidates that satisfy either r19
65/// scoring path before `linked` provides the final contract guard.
66///
67/// `by_token` holds only tokens that two or more candidates share. A token in
68/// exactly one candidate can never raise the shared-token count between two
69/// different candidates, so indexing it would add an N-bit row that only ever
70/// counts a candidate against itself — and self is below the floor every caller
71/// scans from. Skipping those tokens keeps the index proportional to the shared
72/// vocabulary instead of the whole vocabulary, which is what bounds memory when
73/// every record brings new words.
74///
75/// `by_tag` is bounded the same way. A tag counted once in the analyzed
76/// population is carried by exactly one record: either no representative
77/// carries it and its pool is never queried, or the sole carrier is the
78/// representative, whose own bit every caller discards. Either way the pool
79/// cannot change a result, and a log where most tags name a single record
80/// would otherwise pay a full N-bit row for each of them.
81///
82/// `verify` reuses the index with resolved anchors as representatives and the
83/// open cuts as the indexed candidates. That stays sound as long as the
84/// frequencies passed here also count the representatives: a token shared by an
85/// anchor and an open cut then has a count of at least two and is indexed, and
86/// every representative token has a counted frequency to score against.
87pub(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    /// Ascending set positions at or above `floor`. Callers whose candidates
200    /// are sorted can turn an ordered cutoff into a starting word instead of a
201    /// second filtering pass: whole words below the floor are never visited.
202    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
227/// A bit-sliced counter counts each representative token's candidate posting
228/// set in parallel. The data remains a per-candidate document-frequency view:
229/// every candidate contributes at most one bit per deduplicated token.
230struct 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    /// Positions below `floor` are unspecified in the returned set: the caller
304    /// promises to read it with `indices_from(floor)` or tighter. Both callers
305    /// already discard that prefix — triage drops every candidate at or before
306    /// its representative, and verify drops everything up to the resolution —
307    /// so the whole bit-parallel count can start at the floor's word instead of
308    /// computing a prefix that is thrown away. It is the one bound that scales
309    /// with the representative's own position rather than the corpus size.
310    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    /// Runs whichever r19 scoring path can still reach its threshold, and
344    /// reports whether either ran. When neither can, the scored match set is
345    /// provably empty and the tag pool that would mask it is never built.
346    ///
347    /// Only a token with a posting set can raise a candidate's shared-token
348    /// count, so the number of the representative's indexed tokens is an upper
349    /// bound on every count this scan can produce. A threshold above that bound
350    /// is unreachable, and counting against it costs a full pass over the
351    /// candidate space to prove an answer already known. Both paths are skipped
352    /// on the bound instead. Records whose wording is mostly their own are the
353    /// common case in a real log, and they are exactly the ones the bound
354    /// retires.
355    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        // Thresholds rise with the candidate's token count, so the smallest
373        // indexed count carries the smallest threshold in the whole scan.
374        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            // `reachable`, not the token count, is the largest value a counter
381            // can hold, so it is what sizes the planes.
382            self.overlap.reset(word, reachable);
383            for token in &representative.tokens {
384                // An unshared token has no posting set; it could only have
385                // counted the representative against itself.
386                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        // The same bound applies to the rare path, over the rare tokens alone.
402        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    // Count every folded open cut by title. This is a recurrence signal, not
489    // an ID deduplication pass, so two independently materialized records
490    // both contribute when their normalized titles match.
491    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        // The earliest unclaimed candidate (then lowest ID) is the stable
508        // representative. Members must link directly to it; unioning every
509        // pair would turn an A~B~C chain into a transitive A/B/C cluster.
510        // Earlier candidates are already in a cluster or were already compared
511        // against this one, so the self-exclusion is a floor on the scan rather
512        // than a test inside it.
513        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        // Claim only when the cluster is actually reported: members consumed by
535        // a below-threshold cluster must stay free to join a later
536        // representative, or real chronic clusters go unreported.
537        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
585/// Tokens that carry no topical signal. This is the Snowball English stopword
586/// list (https://snowballstem.org/algorithms/english/stop.txt, 174 entries)
587/// passed through blotter's own normalization — lowercased, every
588/// non-alphanumeric character replaced by a space, split on whitespace — and
589/// reduced to the tokens `scoring_tokens` can see: three or more characters.
590/// Deriving it through the normalizer instead of copying it verbatim is what
591/// makes `wouldn`, `doesn` and `let` members: blotter splits `wouldn't` into
592/// `wouldn` and `t`, so the published apostrophe spellings would never match.
593///
594/// Four r19 entries Snowball does not carry are retained because they are
595/// filler in friction narration: `need`, `one`, `use`, `uses`. r19's `to` is
596/// dropped as dead weight — a two-character token never reaches this check.
597///
598/// Frequency cannot do this job at fixture scale. `is_rare` accepts
599/// `df <= max(2, ceil(N/16))`, a token shared by the two candidates under test
600/// always has `df >= 2`, and the floor of 2 exists because a lower floor would
601/// make no shared token rare and retire the path. In a four-candidate analysis
602/// every shared token is rare, so no ratio separates filler from content there.
603/// A common English word is removable as a word at every scale, and as a
604/// frequency only at some. See design doc r44, superseded on the divisor by r53.
605///
606/// Sorted and unique; `scoring_tokens` binary-searches it.
607const 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
743/// Counts tokens and tags over the whole analyzed population — the candidates
744/// plus any representative that is not one of them, as `verify` has. Both
745/// counts decide what the index is allowed to leave out, so both must see the
746/// representatives: a tag or token shared by a representative and one candidate
747/// has to count as shared.
748pub(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    /// True when at least two candidates carry the token, so it can take part
772    /// in a shared-token count between two different candidates.
773    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    /// True when at least two records carry the tag, so its pool can hold a
782    /// record other than the representative that queries it.
783    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    // Identical non-empty normalized titles always link: recurrence of the
803    // exact same title is the strongest chronic signal, so tags must not
804    // suppress it.
805    if !left.normalized_title.is_empty() && left.normalized_title == right.normalized_title {
806        return true;
807    }
808
809    // Otherwise, retain untagged-to-untagged matches: tags are optional, so
810    // untagged near-duplicates remain direct matches. They are the likeliest
811    // bridges, so cluster construction only compares candidates to a stable
812    // representative rather than taking a transitive closure through members.
813    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    // Near-duplicates already have strong evidence in their filtered token
829    // overlap. Reworded descriptions instead need several locally rare terms.
830    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            // The latest member is the final one because the indices are in
862            // stable candidate order. Preserve its title occurrence count for
863            // triage's existing envelope, while exposing every member's count
864            // to read-only consumers that need aggregate evidence.
865            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        // Keyed on the same record whose text the cluster displays, so a
891        // consumer can interpret occurrences against the title it sees.
892        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    /// Every entry must be a token the tokenizer can actually emit. A stopword
918    /// list that disagrees with its own tokenizer silently does nothing:
919    /// scikit-learn documents exactly this against its own default tokenizer,
920    /// which splits `we've` into `we` and `ve`, so listing `we've` without `ve`
921    /// retains `ve`; Nothman, Qin and Yurchak (ACL 2018, W18-2502) found the
922    /// same class of defect across the popular published lists. r44's list is
923    /// derived *through* `normalized_title` so that it does not have it -- 174
924    /// Snowball entries become 118 after normalization and the length floor,
925    /// plus the four r19 retentions -- and this test is what stops a later hand
926    /// edit from reintroducing it.
927    ///
928    /// Asserting against the real normalizer rather than restating its rules
929    /// keeps the two from drifting apart: `normalized_title` lowercases and
930    /// replaces every non-alphanumeric character with a space, so any entry it
931    /// does not return unchanged is one no token can equal. `scoring_tokens`
932    /// then drops tokens of two or fewer characters before the lookup, so a
933    /// shorter entry is unreachable as well.
934    #[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        // This checks the index against the public pair rule, rather than a
978        // second clustering implementation. It covers exact-title matches
979        // across tags, shared-tag and untagged scoring, the rare-token path,
980        // overlapping tag pools, and titles with empty scoring sets.
981        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            // Every scoring token here occurs in exactly one candidate, so none
1002            // of them reach the index. The representative must still match
1003            // itself through its exact title.
1004            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        // `verify` scores resolved anchors against an index built over the open
1033        // cuts alone. A tag or token carried by exactly one anchor and one open
1034        // cut is shared by two records but appears only once among the indexed
1035        // candidates, so counting the representatives is what keeps its pool in
1036        // the index at all.
1037        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    /// A tiny deterministic generator. The suite must stay reproducible, and
1097    /// this needs no distribution quality beyond "spreads the corpus around".
1098    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        // Skipping unshared tokens is only safe if the index still reproduces
1116        // the pair rule exactly. Each seed builds a corpus that mixes a small
1117        // shared vocabulary with per-candidate words nothing else uses, which
1118        // is the shape that drives the document frequency of most tokens to 1.
1119        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                // Words unique to this candidate, so their document frequency
1134                // is 1 and the index must leave them out.
1135                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}