Skip to main content

spar/
textsim.rs

1//! Deciding whether two pieces of prose make the same point.
2//!
3//! Everything spar deduplicates used to compare strings exactly. Two agents
4//! describing one defect, or two runs a week apart describing it again, never
5//! phrase it identically, so exact matching let duplicates straight through: a
6//! follow-up filed twice as two issues, and an issue closed with two comments
7//! saying the same thing in different words.
8//!
9//! This is deliberately shallow. No stemming, no embeddings, nothing that needs
10//! a model. It compares the significant words two texts share, which is enough
11//! to catch a rewording and cheap enough to run on every finding.
12//!
13//! The thresholds lean toward calling things the same, because the two errors
14//! are not symmetric. Treating one defect as two files a duplicate, which is
15//! the complaint. Treating two defects as one still records the second, as a
16//! comment on the first issue rather than an issue of its own, so nothing is
17//! lost and a person can split them.
18
19use std::collections::BTreeSet;
20
21/// Words too common to say anything about what a text is about.
22const NOISE: &[&str] = &[
23    "the", "and", "for", "that", "this", "with", "which", "when", "then", "than", "from", "into",
24    "但", "are", "was", "were", "has", "have", "had", "not", "but", "its", "it's", "their", "they",
25    "there", "here", "same", "still", "also", "only", "any", "all", "can", "will", "would",
26    "should", "could", "does", "did", "done", "being", "been", "because", "while", "after",
27    "before", "since", "each", "every", "some", "such", "them", "these", "those", "what", "where",
28    "who", "why", "how", "you", "your", "our", "one", "two", "new", "now", "may", "might", "must",
29    "issue", "issues", "bug", "fix", "fixes", "fixed", "change", "changes", "changed",
30];
31
32/// Significant words, lowercased. Punctuation goes, short words go, and words
33/// that appear in every bug report go.
34pub fn tokens(text: &str) -> BTreeSet<String> {
35    text.to_lowercase()
36        .split(|c: char| !c.is_alphanumeric())
37        .filter(|word| word.len() > 2)
38        .filter(|word| !NOISE.contains(word))
39        .map(str::to_string)
40        .collect()
41}
42
43/// How much of the smaller text's vocabulary the larger one already contains,
44/// from 0.0 to 1.0.
45///
46/// Containment rather than Jaccard on purpose: a one line title and a paragraph
47/// describing the same defect should read as the same point, and Jaccard
48/// punishes them for differing in length.
49pub fn containment(a: &str, b: &str) -> f64 {
50    let (left, right) = (tokens(a), tokens(b));
51    if left.is_empty() || right.is_empty() {
52        return 0.0;
53    }
54    let shared = left.intersection(&right).count() as f64;
55    let smaller = left.len().min(right.len()) as f64;
56    shared / smaller
57}
58
59/// Words the two texts share. Used to insist on real overlap rather than one
60/// lucky word.
61pub fn shared(a: &str, b: &str) -> usize {
62    tokens(a).intersection(&tokens(b)).count()
63}
64
65/// Whether two texts make the same point.
66///
67/// Needs both a high proportion of shared vocabulary and enough shared words
68/// for that proportion to mean anything: two three-word titles sharing one word
69/// are not the same point, however good the ratio looks.
70pub fn same_point(a: &str, b: &str) -> bool {
71    let a_trim = a.trim();
72    let b_trim = b.trim();
73    if a_trim.is_empty() || b_trim.is_empty() {
74        return a_trim == b_trim;
75    }
76    if a_trim.eq_ignore_ascii_case(b_trim) {
77        return true;
78    }
79    containment(a, b) >= 0.6 && shared(a, b) >= 3
80}
81
82/// Issue and pull request numbers a text cites.
83pub fn references(text: &str) -> BTreeSet<u64> {
84    let mut out = BTreeSet::new();
85    let bytes: Vec<char> = text.chars().collect();
86    for (i, c) in bytes.iter().enumerate() {
87        if *c != '#' {
88            continue;
89        }
90        let digits: String = bytes[i + 1..]
91            .iter()
92            .take_while(|d| d.is_ascii_digit())
93            .collect();
94        if let Ok(n) = digits.parse::<u64>() {
95            out.insert(n);
96        }
97    }
98    out
99}
100
101/// Whether two reviewers gave the same reason for declining an issue.
102///
103/// Looser than [`same_point`], because a reason is one sentence and two models
104/// write it with almost no words in common. On the run that prompted this, one
105/// wrote "Same root cause and same fix as #485" and the other "This is another
106/// manifestation of #485's unrecorded same-peer reset": two shared words, and
107/// the reader saw the same point twice. Citing the same issue is the signal
108/// that survives the rewording.
109pub fn same_reason(a: &str, b: &str) -> bool {
110    if same_point(a, b) {
111        return true;
112    }
113    let cited: BTreeSet<u64> = references(a)
114        .intersection(&references(b))
115        .copied()
116        .collect();
117    !cited.is_empty() && containment(a, b) >= 0.15
118}
119
120/// Strip the provenance line spar stamps onto every follow-up it files.
121///
122/// Without this, every issue from one run shares "Found while working on #482"
123/// and so looks a little like every other, which is exactly the wrong thumb on
124/// the scale when the question is whether two of them are the same defect.
125pub fn strip_provenance(text: &str) -> String {
126    const STAMPS: [&str; 2] = ["found while working on #", "from #"];
127    let lower = text.to_lowercase();
128    let mut out = String::with_capacity(text.len());
129    let mut cut_to = 0usize;
130    let chars: Vec<char> = text.chars().collect();
131    let lower_chars: Vec<char> = lower.chars().collect();
132
133    let mut i = 0usize;
134    while i < chars.len() {
135        let matched = STAMPS.iter().find(|stamp| {
136            let s: Vec<char> = stamp.chars().collect();
137            i + s.len() <= lower_chars.len() && lower_chars[i..i + s.len()] == s[..]
138        });
139        match matched {
140            Some(stamp) => {
141                // Swallow the phrase and the issue number after it.
142                let mut j = i + stamp.chars().count();
143                while j < chars.len() && chars[j].is_ascii_digit() {
144                    j += 1;
145                }
146                if j < chars.len() && chars[j] == '.' {
147                    j += 1;
148                }
149                out.extend(&chars[cut_to..i]);
150                cut_to = j;
151                i = j;
152            }
153            None => i += 1,
154        }
155    }
156    out.extend(&chars[cut_to..]);
157    out
158}
159
160/// Whether two issues describe the same defect, compared on title and body
161/// together.
162///
163/// The threshold is measured, not guessed. Against the ten follow-ups one real
164/// run filed, where two pairs were confirmed duplicates by their own closing
165/// comments, the duplicates scored 0.50 and 0.46 while the closest genuinely
166/// distinct pair scored 0.35. 0.40 sits in that gap with room on both sides,
167/// and the test holds it there.
168const SAME_SUBJECT: f64 = 0.40;
169
170pub fn same_subject(a: &str, b: &str) -> bool {
171    let (a, b) = (strip_provenance(a), strip_provenance(b));
172    containment(&a, &b) >= SAME_SUBJECT && shared(&a, &b) >= 5
173}
174
175/// How much of a title has to be shared before a body can be read as covering
176/// it.
177///
178/// Lower than `SAME_SUBJECT`, because two people naming one defect share fewer
179/// words in a title than in a whole issue, and the point of the test is not to
180/// be strict but to require the subject to appear at all.
181const SAME_TITLE: f64 = 0.34;
182
183/// Whether an existing issue already describes this one.
184///
185/// The title is weighed on its own, and that is the whole change from
186/// `same_subject`. `containment` normalises by the smaller token set, so a long
187/// umbrella issue, a "known issues" list, or a tracker has a large enough
188/// vocabulary that any short finding about the same module scores as covered,
189/// and the finding is then dropped without anything being written. Requiring
190/// the titles to overlap costs a genuine duplicate nothing: two people naming
191/// one defect name the same thing.
192pub fn covers(new_title: &str, new_body: &str, old_title: &str, old_body: &str) -> bool {
193    let mine = format!("{new_title} {new_body}");
194    let theirs = format!("{old_title} {old_body}");
195    same_subject(&mine, &theirs) && titles_overlap(new_title, old_title)
196}
197
198fn titles_overlap(a: &str, b: &str) -> bool {
199    let (a, b) = (strip_provenance(a), strip_provenance(b));
200    if a.trim().eq_ignore_ascii_case(b.trim()) {
201        return true;
202    }
203    containment(&a, &b) >= SAME_TITLE && shared(&a, &b) >= 2
204}
205
206/// Whether `candidate` says anything `existing` does not.
207///
208/// The question behind "should this be a comment on the issue that already
209/// exists, or nothing at all". Repeating what an issue already says is the same
210/// noise as filing it twice.
211pub fn adds_information(candidate: &str, existing: &str) -> bool {
212    let new = tokens(candidate);
213    if new.is_empty() {
214        return false;
215    }
216    let known = tokens(existing);
217    let unknown = new.difference(&known).count() as f64;
218    unknown / new.len() as f64 >= 0.3
219}
220
221/// Collapse texts that make the same point, keeping the fullest wording of each.
222pub fn dedupe(texts: impl IntoIterator<Item = String>) -> Vec<String> {
223    dedupe_by(texts, same_point)
224}
225
226/// Collapse texts under a caller supplied notion of sameness.
227pub fn dedupe_by(
228    texts: impl IntoIterator<Item = String>,
229    same: impl Fn(&str, &str) -> bool,
230) -> Vec<String> {
231    let mut kept: Vec<String> = Vec::new();
232    for text in texts {
233        if text.trim().is_empty() {
234            continue;
235        }
236        match kept.iter_mut().find(|seen| same(seen, &text)) {
237            // The longer wording is usually the one carrying the evidence.
238            Some(seen) => {
239                if text.len() > seen.len() {
240                    *seen = text;
241                }
242            }
243            None => kept.push(text),
244        }
245    }
246    kept
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    /// The two comments that landed on beignet#489, verbatim. spar posted both
254    /// because they are not the same string, and a reader saw the same sentence
255    /// twice.
256    const REAL_A: &str = "Duplicate of #487, which reports the same refused-teardown state \
257                          contradiction (connectedToElectrum false while the retained peer still \
258                          serves) and is fixed by the same change.";
259    const REAL_B: &str =
260        "This is a duplicate of #487, which covers the same refused-teardown state mismatch.";
261
262    #[test]
263    fn the_two_comments_from_the_real_issue_are_one_point() {
264        assert!(
265            same_point(REAL_A, REAL_B),
266            "{}",
267            containment(REAL_A, REAL_B)
268        );
269    }
270
271    #[test]
272    fn deduping_them_keeps_the_one_carrying_the_evidence() {
273        let out = dedupe([REAL_B.to_string(), REAL_A.to_string()]);
274        assert_eq!(1, out.len());
275        assert!(out[0].contains("connectedToElectrum"), "{:?}", out[0]);
276    }
277
278    /// Two titles for one defect, from a real run. They share three words, and
279    /// no honest lexical threshold calls that a match. This is exactly why
280    /// issues are compared on their bodies as well: see `same_subject`.
281    #[test]
282    fn titles_alone_are_too_thin_to_match_a_reworded_defect() {
283        let a = "Failed switch reports a live peer as disconnected";
284        let b = "A refused teardown marks the wallet disconnected while the peer is still live";
285        assert!(!same_point(a, b), "{}", containment(a, b));
286    }
287
288    #[test]
289    fn genuinely_different_defects_stay_apart() {
290        for (a, b) in [
291            (
292                "Retry loop never terminates when max_attempts is unset",
293                "Headers are restored only for the instance that reset the client",
294            ),
295            (
296                "Subscription errors permanently clear restore debt",
297                "attemptConnect's doc comment no longer describes what it does",
298            ),
299            ("Log wording", "Unbounded allocation on empty input"),
300        ] {
301            assert!(
302                !same_point(a, b),
303                "merged two different defects:\n  {a}\n  {b}"
304            );
305        }
306    }
307
308    /// A high ratio on two words is luck, not agreement.
309    #[test]
310    fn a_short_title_needs_real_overlap_not_a_lucky_word() {
311        assert!(!same_point("Timeout handling", "Timeout value"));
312    }
313
314    #[test]
315    fn identical_text_is_always_the_same_point() {
316        assert!(same_point("Anything at all", "anything at all"));
317        assert!(same_point("x", "x"));
318    }
319
320    #[test]
321    fn empty_text_matches_only_empty_text() {
322        assert!(same_point("", "  "));
323        assert!(!same_point("", "something"));
324    }
325
326    #[test]
327    fn new_evidence_counts_as_new_information() {
328        let existing = "The retry loop never terminates when max_attempts is unset.";
329        assert!(adds_information(
330            "Reproduced on macOS with tokio 1.38: the guard on line 91 compares against Some(0).",
331            existing
332        ));
333    }
334
335    #[test]
336    fn a_restatement_adds_nothing() {
337        let existing = "The retry loop never terminates when max_attempts is unset.";
338        assert!(!adds_information(
339            "The retry loop never terminates if max_attempts is unset.",
340            existing
341        ));
342    }
343
344    #[test]
345    fn dedupe_keeps_distinct_points_and_drops_blanks() {
346        let out = dedupe([
347            "Retry loop never terminates".to_string(),
348            "   ".to_string(),
349            "Headers are restored only for the initiating instance".to_string(),
350        ]);
351        assert_eq!(2, out.len());
352    }
353
354    #[test]
355    fn tokens_ignore_punctuation_and_filler() {
356        let t = tokens("The retry-loop, which never terminates!");
357        assert!(t.contains("retry") && t.contains("loop") && t.contains("terminates"));
358        assert!(!t.contains("the") && !t.contains("which"));
359    }
360}
361
362#[cfg(test)]
363mod real_corpus {
364    use super::*;
365    use std::collections::BTreeMap;
366
367    /// The ten follow-ups one real run filed on a real repository, captured
368    /// verbatim. Two pairs of them are confirmed duplicates by their own
369    /// closing comments, which is what the thresholds here are measured
370    /// against rather than guessed at.
371    const CORPUS: &str = include_str!("../tests/fixtures/real_followups.json");
372
373    fn issues() -> BTreeMap<u64, String> {
374        let rows: Vec<serde_json::Value> = serde_json::from_str(CORPUS).expect("fixture");
375        rows.into_iter()
376            .map(|r| {
377                let number = r["number"].as_u64().expect("number");
378                let text = format!(
379                    "{} {}",
380                    r["title"].as_str().unwrap_or(""),
381                    r["body"].as_str().unwrap_or("")
382                );
383                (number, text)
384            })
385            .collect()
386    }
387
388    /// #489 duplicates #487, and #490 duplicates #485. Both were closed saying
389    /// so. spar filed them anyway, because it compared titles for exact
390    /// equality.
391    #[test]
392    fn both_duplicates_that_were_actually_filed_are_caught() {
393        let by = issues();
394        for (dup, original) in [(489u64, 487u64), (490, 485)] {
395            let score = containment(&by[&dup], &by[&original]);
396            assert!(
397                same_subject(&by[&dup], &by[&original]),
398                "#{dup} vs #{original} scored {score:.3}"
399            );
400            // And with headroom above the bar, not scraping it.
401            assert!(
402                score >= 0.44,
403                "#{dup} vs #{original} only scored {score:.3}"
404            );
405        }
406    }
407
408    /// The title carries its own weight, and the real duplicates survive it.
409    ///
410    /// Weighing the title separately is what stops a long issue absorbing every
411    /// short finding in its module, and it has to cost a genuine duplicate
412    /// nothing: two people naming one defect name the same thing.
413    #[test]
414    fn the_real_duplicates_survive_the_title_test() {
415        let rows: Vec<serde_json::Value> = serde_json::from_str(CORPUS).expect("fixture");
416        let by: BTreeMap<u64, (String, String)> = rows
417            .into_iter()
418            .map(|r| {
419                (
420                    r["number"].as_u64().expect("number"),
421                    (
422                        r["title"].as_str().unwrap_or("").to_string(),
423                        r["body"].as_str().unwrap_or("").to_string(),
424                    ),
425                )
426            })
427            .collect();
428        for (dup, original) in [(489u64, 487u64), (490, 485)] {
429            let (dt, db) = &by[&dup];
430            let (ot, ob) = &by[&original];
431            assert!(covers(dt, db, ot, ob), "#{dup} vs #{original}");
432        }
433    }
434
435    /// A long umbrella issue has a large enough vocabulary to contain any short
436    /// finding about the same module, and `containment` normalises by the
437    /// smaller token set, so it scored as covering all of them. The finding was
438    /// then dropped with nothing written anywhere.
439    #[test]
440    fn a_long_umbrella_issue_does_not_absorb_every_finding_in_its_module() {
441        let umbrella_title = "Known issues in the retry and cache layers";
442        let umbrella_body = "\
443            A running list. The retry loop needs a cap. The cache eviction is \
444            wrong under load. The connection pool leaks sockets on failover. \
445            The backoff calculation ignores Retry-After. Header parsing accepts \
446            duplicate keys. The metrics counter double counts retries. Cache \
447            keys collide across tenants. The socket timeout is hard coded.";
448        let finding_title = "Cache keys collide across tenants";
449        let finding_body = "Two tenants with the same object name share a cache entry.";
450
451        assert!(
452            same_subject(
453                &format!("{finding_title} {finding_body}"),
454                &format!("{umbrella_title} {umbrella_body}")
455            ),
456            "the old rule has to absorb it, or this proves nothing"
457        );
458        assert!(
459            !covers(finding_title, finding_body, umbrella_title, umbrella_body),
460            "a list that mentions the module absorbed a finding and wrote nothing"
461        );
462
463        // An issue actually about that defect still covers it, however the
464        // wording differs.
465        assert!(
466            covers(
467                finding_title,
468                finding_body,
469                "Cache keys collide between tenants",
470                "Two tenants storing the same object name share one cache entry."
471            ),
472            "a real duplicate was filed twice"
473        );
474    }
475
476    /// Everything else in that run is a genuinely separate defect, and merging
477    /// any of them would be worse than the duplicate this is meant to prevent.
478    #[test]
479    fn no_two_distinct_defects_are_merged() {
480        let by = issues();
481        let dups = [(489u64, 487u64), (490, 485)];
482        let numbers: Vec<u64> = by.keys().copied().collect();
483        let mut worst = (0.0f64, 0u64, 0u64);
484
485        for (i, a) in numbers.iter().enumerate() {
486            for b in &numbers[i + 1..] {
487                if dups.contains(&(*a, *b)) || dups.contains(&(*b, *a)) {
488                    continue;
489                }
490                let score = containment(&by[a], &by[b]);
491                if score > worst.0 {
492                    worst = (score, *a, *b);
493                }
494                assert!(
495                    !same_subject(&by[a], &by[b]),
496                    "merged #{a} and #{b}, which are different defects (scored {score:.2})"
497                );
498            }
499        }
500        // Headroom matters as much as the verdict: a threshold with no gap
501        // under it is luck rather than a threshold. Measured, the closest
502        // distinct pair is 0.35 against a bar of 0.40.
503        assert!(
504            worst.0 <= 0.36,
505            "#{} and #{} scored {:.3}, leaving no headroom under the threshold",
506            worst.1,
507            worst.2,
508            worst.0
509        );
510    }
511
512    /// Every follow-up from one run ends with the same provenance line. Left
513    /// in, it makes unrelated issues look alike.
514    #[test]
515    fn the_provenance_line_does_not_count_toward_similarity() {
516        let a = "Something entirely unrelated. Found while working on #482.";
517        let b = "A different thing altogether. Found while working on #482.";
518        assert!(
519            !strip_provenance(a).contains("482"),
520            "{:?}",
521            strip_provenance(a)
522        );
523        assert!(!same_subject(a, b));
524    }
525
526    #[test]
527    fn stripping_provenance_leaves_the_rest_intact() {
528        assert_eq!(
529            "The retry loop spins. ",
530            strip_provenance("The retry loop spins. Found while working on #482.")
531        );
532    }
533
534    /// The two comments that landed together on the real closed issue. Almost
535    /// no words in common, but both cite the issue they duplicate.
536    #[test]
537    fn reasons_citing_the_same_issue_are_one_reason() {
538        let a = "Same root cause and same fix as #485 (the client's own same-target reconnect \
539                 clears the bookkeeping while stopPeerIfServerChanged reports clientReset:false, \
540                 so no restore debt is recorded), so it is covered by the same change.";
541        let b = "This is another manifestation of #485's unrecorded same-peer reset and is \
542                 covered by restoring subscriptions there.";
543        assert!(!same_point(a, b), "lexically they really are far apart");
544        assert!(same_reason(a, b), "but they make the same point");
545
546        let kept = dedupe_by([b.to_string(), a.to_string()], same_reason);
547        assert_eq!(1, kept.len());
548        assert!(
549            kept[0].contains("root cause"),
550            "the fuller wording survives"
551        );
552    }
553
554    #[test]
555    fn reasons_citing_different_issues_stay_apart() {
556        assert!(!same_reason(
557            "Duplicate of #485, same root cause.",
558            "Superseded by #999, which takes a different approach entirely."
559        ));
560    }
561
562    #[test]
563    fn references_are_extracted_from_prose() {
564        assert_eq!(
565            vec![12u64, 487],
566            references("Duplicate of #487, see also #12.")
567                .into_iter()
568                .collect::<Vec<_>>()
569        );
570        assert!(references("no numbers here").is_empty());
571        assert!(references("# not a reference").is_empty());
572    }
573}