use std::collections::{BTreeSet, HashSet};
pub const NEAR_DUP_THRESHOLD: f32 = 0.80;
pub const NEAR_DUP_K: usize = 3;
pub const TEXT_NEAR_DUP_CANDIDATES: usize = NEAR_DUP_K + 5;
pub const NEAR_DUP_REVIEW: f32 = 0.68;
pub const TEXT_NEAR_DUP_CONTAINMENT: f64 = 0.7;
pub const TEXT_BAND_MIN_TOKENS: usize = 6;
const DUP_FWD_CUES: &[&str] = &[
"not", "never", "no", "longer", "instead", "without", "rather", "over", "versus", "vs",
"replaced", "replaces", "removed", "remove",
];
const DUP_BWD_CUES: &[&str] = &[
"dropped",
"drops",
"removed",
"remove",
"gone",
"deprecated",
"retired",
"stopped",
"killed",
"disabled",
"discontinued",
"replaced",
"replaces",
];
const DUP_STOP: &[&str] = &[
"a", "an", "at", "the", "of", "to", "in", "on", "for", "its", "it", "is", "are", "as", "by",
"with", "and", "or", "now", "only", "both", "this", "that", "using", "use", "uses", "chose",
"runs", "run", "was", "were", "be", "been", "their", "them", "people", "up",
];
const DUP_FWD_WINDOW: usize = 4;
pub fn tokens(s: &str) -> BTreeSet<String> {
s.split_whitespace().map(|t| t.to_lowercase()).collect()
}
pub fn containment_of_sets(a: &BTreeSet<String>, b: &BTreeSet<String>) -> f64 {
if a.is_empty() && b.is_empty() {
return 1.0;
}
if a.is_empty() || b.is_empty() {
return 0.0;
}
let intersection = a.intersection(b).count() as f64;
let min_len = (a.len().min(b.len())) as f64;
intersection / min_len
}
pub fn dup_band(similarity: f32) -> &'static str {
if similarity >= NEAR_DUP_THRESHOLD {
"likely"
} else {
"possible"
}
}
pub fn text_dup_band(containment: f64, min_set_len: usize) -> &'static str {
if min_set_len < TEXT_BAND_MIN_TOKENS {
"possible"
} else {
dup_band(containment as f32)
}
}
fn dup_is_cue(w: &str) -> bool {
DUP_FWD_CUES.contains(&w) || DUP_BWD_CUES.contains(&w)
}
fn dup_singularize(t: &str) -> &str {
if t.len() > 3 && t.ends_with('s') {
&t[..t.len() - 1]
} else {
t
}
}
fn dup_analyze(s: &str) -> (HashSet<String>, HashSet<String>) {
let lower = s.to_lowercase();
let is_stop = |w: &str| DUP_STOP.contains(&w);
let mut negated: HashSet<String> = HashSet::new();
let mut content: HashSet<String> = HashSet::new();
for clause in lower.split(['.', ',', ';', ':', '!', '?', '(', ')']) {
let toks: Vec<&str> = clause
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|w| !w.is_empty())
.collect();
for (i, t) in toks.iter().enumerate() {
if DUP_FWD_CUES.contains(t) {
let mut taken = 0usize;
for w in &toks[i + 1..] {
if taken == DUP_FWD_WINDOW {
break;
}
if is_stop(w) || dup_is_cue(w) {
continue;
}
negated.insert(dup_singularize(w).to_string());
taken += 1;
}
}
if DUP_BWD_CUES.contains(t) {
for w in toks[..i].iter().rev() {
if is_stop(w) || dup_is_cue(w) {
continue;
}
negated.insert(dup_singularize(w).to_string());
break;
}
}
}
content.extend(
toks.iter()
.filter(|w| !is_stop(w) && !dup_is_cue(w))
.map(|w| dup_singularize(w).to_string()),
);
}
let asserted = content.difference(&negated).cloned().collect();
(asserted, negated)
}
pub fn is_supersession(a: &str, b: &str) -> bool {
let (a_assert, a_neg) = dup_analyze(a);
let (b_assert, b_neg) = dup_analyze(b);
a_neg.intersection(&b_assert).next().is_some() || b_neg.intersection(&a_assert).next().is_some()
}
pub fn dup_relation(a: &str, b: &str) -> &'static str {
if is_supersession(a, b) {
"supersession"
} else {
"duplicate"
}
}
#[cfg(test)]
mod dup_classify_tests {
use super::{containment_of_sets, dup_band, dup_relation, is_supersession, text_dup_band};
#[test]
fn supersession_detector_separates_contradictions_from_restatements() {
let same = [
("The team chose redb as TopoDB's storage engine for its single-file ACID guarantees",
"TopoDB persists its data in the redb embedded key-value database"),
("TopoDB uses redb as its storage backend", "The storage engine behind TopoDB is redb"),
("Drew prefers Colima over Docker Desktop",
"Drew runs containers on Colima instead of Docker Desktop"),
("CI runs fmt, clippy, and tests on ubuntu and windows",
"The CI pipeline executes formatting, linting, and the test suite on both ubuntu and windows runners"),
("the auth service issues JWT tokens to sign in users",
"auth uses JSON Web Tokens to authenticate and log people in"),
("CI runs only on ubuntu (windows dropped)",
"CI no longer runs on windows, only ubuntu"),
];
let contradict = [
(
"TopoDB stores its data in redb",
"TopoDB now stores its data in sled, not redb",
),
(
"the auth service issues JWT tokens",
"the auth service now issues opaque session tokens, not JWTs",
),
(
"CI runs on ubuntu and windows",
"CI no longer runs on windows, only ubuntu",
),
(
"the redb backend is used for storage",
"the redb backend was removed",
),
(
"use the staging db",
"never point load tests at the staging db",
),
];
for (a, b) in same {
assert!(
!is_supersession(a, b),
"should read as a duplicate: {a:?} / {b:?}"
);
assert_eq!(dup_relation(a, b), "duplicate");
}
for (a, b) in contradict {
assert!(
is_supersession(a, b),
"should read as a supersession: {a:?} / {b:?}"
);
assert_eq!(dup_relation(a, b), "supersession");
}
}
#[test]
fn is_supersession_is_symmetric() {
let a = "TopoDB stores its data in redb";
let b = "TopoDB now stores its data in sled, not redb";
assert_eq!(is_supersession(a, b), is_supersession(b, a));
}
#[test]
fn band_splits_at_the_strong_floor() {
assert_eq!(dup_band(0.95), "likely");
assert_eq!(dup_band(0.80), "likely");
assert_eq!(dup_band(0.799), "possible");
assert_eq!(dup_band(0.70), "possible");
}
#[test]
fn text_band_caps_small_sets_at_possible() {
assert_eq!(text_dup_band(1.0, 3), "possible");
assert_eq!(text_dup_band(1.0, 5), "possible");
assert_eq!(text_dup_band(0.8333, 6), "likely");
assert_eq!(text_dup_band(0.75, 6), "possible");
}
#[test]
fn containment_empty_set_rules() {
use std::collections::BTreeSet;
let empty: BTreeSet<String> = BTreeSet::new();
let full: BTreeSet<String> = ["staging".to_string()].into_iter().collect();
assert_eq!(containment_of_sets(&empty, &empty), 1.0);
assert_eq!(containment_of_sets(&empty, &full), 0.0);
assert_eq!(containment_of_sets(&full, &empty), 0.0);
}
}