1use std::collections::BTreeSet;
20
21const 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
32pub 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
43pub 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
59pub fn shared(a: &str, b: &str) -> usize {
62 tokens(a).intersection(&tokens(b)).count()
63}
64
65pub 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
82pub 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
101pub 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
120pub 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 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
160const 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
175pub 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
190pub fn dedupe(texts: impl IntoIterator<Item = String>) -> Vec<String> {
192 dedupe_by(texts, same_point)
193}
194
195pub 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 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 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 #[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 #[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 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 #[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 assert!(
371 score >= 0.44,
372 "#{dup} vs #{original} only scored {score:.3}"
373 );
374 }
375 }
376
377 #[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 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 #[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 #[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}