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/// Whether `candidate` says anything `existing` does not.
176///
177/// The question behind "should this be a comment on the issue that already
178/// exists, or nothing at all". Repeating what an issue already says is the same
179/// noise as filing it twice.
180pub fn adds_information(candidate: &str, existing: &str) -> bool {
181    let new = tokens(candidate);
182    if new.is_empty() {
183        return false;
184    }
185    let known = tokens(existing);
186    let unknown = new.difference(&known).count() as f64;
187    unknown / new.len() as f64 >= 0.3
188}
189
190/// Collapse texts that make the same point, keeping the fullest wording of each.
191pub fn dedupe(texts: impl IntoIterator<Item = String>) -> Vec<String> {
192    dedupe_by(texts, same_point)
193}
194
195/// Collapse texts under a caller supplied notion of sameness.
196pub fn dedupe_by(
197    texts: impl IntoIterator<Item = String>,
198    same: impl Fn(&str, &str) -> bool,
199) -> Vec<String> {
200    let mut kept: Vec<String> = Vec::new();
201    for text in texts {
202        if text.trim().is_empty() {
203            continue;
204        }
205        match kept.iter_mut().find(|seen| same(seen, &text)) {
206            // The longer wording is usually the one carrying the evidence.
207            Some(seen) => {
208                if text.len() > seen.len() {
209                    *seen = text;
210                }
211            }
212            None => kept.push(text),
213        }
214    }
215    kept
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    /// The two comments that landed on beignet#489, verbatim. spar posted both
223    /// because they are not the same string, and a reader saw the same sentence
224    /// twice.
225    const REAL_A: &str = "Duplicate of #487, which reports the same refused-teardown state \
226                          contradiction (connectedToElectrum false while the retained peer still \
227                          serves) and is fixed by the same change.";
228    const REAL_B: &str =
229        "This is a duplicate of #487, which covers the same refused-teardown state mismatch.";
230
231    #[test]
232    fn the_two_comments_from_the_real_issue_are_one_point() {
233        assert!(
234            same_point(REAL_A, REAL_B),
235            "{}",
236            containment(REAL_A, REAL_B)
237        );
238    }
239
240    #[test]
241    fn deduping_them_keeps_the_one_carrying_the_evidence() {
242        let out = dedupe([REAL_B.to_string(), REAL_A.to_string()]);
243        assert_eq!(1, out.len());
244        assert!(out[0].contains("connectedToElectrum"), "{:?}", out[0]);
245    }
246
247    /// Two titles for one defect, from a real run. They share three words, and
248    /// no honest lexical threshold calls that a match. This is exactly why
249    /// issues are compared on their bodies as well: see `same_subject`.
250    #[test]
251    fn titles_alone_are_too_thin_to_match_a_reworded_defect() {
252        let a = "Failed switch reports a live peer as disconnected";
253        let b = "A refused teardown marks the wallet disconnected while the peer is still live";
254        assert!(!same_point(a, b), "{}", containment(a, b));
255    }
256
257    #[test]
258    fn genuinely_different_defects_stay_apart() {
259        for (a, b) in [
260            (
261                "Retry loop never terminates when max_attempts is unset",
262                "Headers are restored only for the instance that reset the client",
263            ),
264            (
265                "Subscription errors permanently clear restore debt",
266                "attemptConnect's doc comment no longer describes what it does",
267            ),
268            ("Log wording", "Unbounded allocation on empty input"),
269        ] {
270            assert!(
271                !same_point(a, b),
272                "merged two different defects:\n  {a}\n  {b}"
273            );
274        }
275    }
276
277    /// A high ratio on two words is luck, not agreement.
278    #[test]
279    fn a_short_title_needs_real_overlap_not_a_lucky_word() {
280        assert!(!same_point("Timeout handling", "Timeout value"));
281    }
282
283    #[test]
284    fn identical_text_is_always_the_same_point() {
285        assert!(same_point("Anything at all", "anything at all"));
286        assert!(same_point("x", "x"));
287    }
288
289    #[test]
290    fn empty_text_matches_only_empty_text() {
291        assert!(same_point("", "  "));
292        assert!(!same_point("", "something"));
293    }
294
295    #[test]
296    fn new_evidence_counts_as_new_information() {
297        let existing = "The retry loop never terminates when max_attempts is unset.";
298        assert!(adds_information(
299            "Reproduced on macOS with tokio 1.38: the guard on line 91 compares against Some(0).",
300            existing
301        ));
302    }
303
304    #[test]
305    fn a_restatement_adds_nothing() {
306        let existing = "The retry loop never terminates when max_attempts is unset.";
307        assert!(!adds_information(
308            "The retry loop never terminates if max_attempts is unset.",
309            existing
310        ));
311    }
312
313    #[test]
314    fn dedupe_keeps_distinct_points_and_drops_blanks() {
315        let out = dedupe([
316            "Retry loop never terminates".to_string(),
317            "   ".to_string(),
318            "Headers are restored only for the initiating instance".to_string(),
319        ]);
320        assert_eq!(2, out.len());
321    }
322
323    #[test]
324    fn tokens_ignore_punctuation_and_filler() {
325        let t = tokens("The retry-loop, which never terminates!");
326        assert!(t.contains("retry") && t.contains("loop") && t.contains("terminates"));
327        assert!(!t.contains("the") && !t.contains("which"));
328    }
329}
330
331#[cfg(test)]
332mod real_corpus {
333    use super::*;
334    use std::collections::BTreeMap;
335
336    /// The ten follow-ups one real run filed on a real repository, captured
337    /// verbatim. Two pairs of them are confirmed duplicates by their own
338    /// closing comments, which is what the thresholds here are measured
339    /// against rather than guessed at.
340    const CORPUS: &str = include_str!("../tests/fixtures/real_followups.json");
341
342    fn issues() -> BTreeMap<u64, String> {
343        let rows: Vec<serde_json::Value> = serde_json::from_str(CORPUS).expect("fixture");
344        rows.into_iter()
345            .map(|r| {
346                let number = r["number"].as_u64().expect("number");
347                let text = format!(
348                    "{} {}",
349                    r["title"].as_str().unwrap_or(""),
350                    r["body"].as_str().unwrap_or("")
351                );
352                (number, text)
353            })
354            .collect()
355    }
356
357    /// #489 duplicates #487, and #490 duplicates #485. Both were closed saying
358    /// so. spar filed them anyway, because it compared titles for exact
359    /// equality.
360    #[test]
361    fn both_duplicates_that_were_actually_filed_are_caught() {
362        let by = issues();
363        for (dup, original) in [(489u64, 487u64), (490, 485)] {
364            let score = containment(&by[&dup], &by[&original]);
365            assert!(
366                same_subject(&by[&dup], &by[&original]),
367                "#{dup} vs #{original} scored {score:.3}"
368            );
369            // And with headroom above the bar, not scraping it.
370            assert!(
371                score >= 0.44,
372                "#{dup} vs #{original} only scored {score:.3}"
373            );
374        }
375    }
376
377    /// Everything else in that run is a genuinely separate defect, and merging
378    /// any of them would be worse than the duplicate this is meant to prevent.
379    #[test]
380    fn no_two_distinct_defects_are_merged() {
381        let by = issues();
382        let dups = [(489u64, 487u64), (490, 485)];
383        let numbers: Vec<u64> = by.keys().copied().collect();
384        let mut worst = (0.0f64, 0u64, 0u64);
385
386        for (i, a) in numbers.iter().enumerate() {
387            for b in &numbers[i + 1..] {
388                if dups.contains(&(*a, *b)) || dups.contains(&(*b, *a)) {
389                    continue;
390                }
391                let score = containment(&by[a], &by[b]);
392                if score > worst.0 {
393                    worst = (score, *a, *b);
394                }
395                assert!(
396                    !same_subject(&by[a], &by[b]),
397                    "merged #{a} and #{b}, which are different defects (scored {score:.2})"
398                );
399            }
400        }
401        // Headroom matters as much as the verdict: a threshold with no gap
402        // under it is luck rather than a threshold. Measured, the closest
403        // distinct pair is 0.35 against a bar of 0.40.
404        assert!(
405            worst.0 <= 0.36,
406            "#{} and #{} scored {:.3}, leaving no headroom under the threshold",
407            worst.1,
408            worst.2,
409            worst.0
410        );
411    }
412
413    /// Every follow-up from one run ends with the same provenance line. Left
414    /// in, it makes unrelated issues look alike.
415    #[test]
416    fn the_provenance_line_does_not_count_toward_similarity() {
417        let a = "Something entirely unrelated. Found while working on #482.";
418        let b = "A different thing altogether. Found while working on #482.";
419        assert!(
420            !strip_provenance(a).contains("482"),
421            "{:?}",
422            strip_provenance(a)
423        );
424        assert!(!same_subject(a, b));
425    }
426
427    #[test]
428    fn stripping_provenance_leaves_the_rest_intact() {
429        assert_eq!(
430            "The retry loop spins. ",
431            strip_provenance("The retry loop spins. Found while working on #482.")
432        );
433    }
434
435    /// The two comments that landed together on the real closed issue. Almost
436    /// no words in common, but both cite the issue they duplicate.
437    #[test]
438    fn reasons_citing_the_same_issue_are_one_reason() {
439        let a = "Same root cause and same fix as #485 (the client's own same-target reconnect \
440                 clears the bookkeeping while stopPeerIfServerChanged reports clientReset:false, \
441                 so no restore debt is recorded), so it is covered by the same change.";
442        let b = "This is another manifestation of #485's unrecorded same-peer reset and is \
443                 covered by restoring subscriptions there.";
444        assert!(!same_point(a, b), "lexically they really are far apart");
445        assert!(same_reason(a, b), "but they make the same point");
446
447        let kept = dedupe_by([b.to_string(), a.to_string()], same_reason);
448        assert_eq!(1, kept.len());
449        assert!(
450            kept[0].contains("root cause"),
451            "the fuller wording survives"
452        );
453    }
454
455    #[test]
456    fn reasons_citing_different_issues_stay_apart() {
457        assert!(!same_reason(
458            "Duplicate of #485, same root cause.",
459            "Superseded by #999, which takes a different approach entirely."
460        ));
461    }
462
463    #[test]
464    fn references_are_extracted_from_prose() {
465        assert_eq!(
466            vec![12u64, 487],
467            references("Duplicate of #487, see also #12.")
468                .into_iter()
469                .collect::<Vec<_>>()
470        );
471        assert!(references("no numbers here").is_empty());
472        assert!(references("# not a reference").is_empty());
473    }
474}