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
175const SAME_TITLE: f64 = 0.34;
182
183pub 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
206pub 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
221pub fn dedupe(texts: impl IntoIterator<Item = String>) -> Vec<String> {
223 dedupe_by(texts, same_point)
224}
225
226pub 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 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 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 #[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 #[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 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 #[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 assert!(
402 score >= 0.44,
403 "#{dup} vs #{original} only scored {score:.3}"
404 );
405 }
406 }
407
408 #[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 #[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 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 #[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 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 #[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 #[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}