File size: 8,404 Bytes
58258b8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | """Tests for MinHashDeduplicator. Skipped if datasketch is not installed."""
from __future__ import annotations
import pytest
pytest.importorskip("datasketch")
from sdg.preprocessing.dedupe.minhash import MinHashDeduplicator
# ββ Boundary cases βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_empty_input_returns_empty():
assert MinHashDeduplicator().dedup([], show_progress=False) == []
def test_single_item_returns_single():
assert MinHashDeduplicator().dedup(["only one"], show_progress=False) == [0]
# ββ Identity / disjoint ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_two_identical_items_collapse_to_one():
text = "Solve the equation x squared minus four equals zero showing every step"
keep = MinHashDeduplicator().dedup([text, text], show_progress=False)
assert len(keep) == 1
def test_two_completely_different_items_both_kept():
keep = MinHashDeduplicator().dedup(
[
"describe the lifecycle of a butterfly in detail",
"compute the integral of sine x from zero to pi",
],
show_progress=False,
)
assert len(keep) == 2
# ββ Threshold behavior βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_near_duplicate_above_threshold_collapses():
# Tail-word edit only (one shingle differs) -> exact Jaccard ~0.93, safely
# above the 0.7 threshold even after exact-Jaccard candidate verification.
base = (
"Solve the equation x squared minus four equals zero. Show all of your work "
"step by step and explain your reasoning so that a beginner can follow it easily today."
)
near = (
"Solve the equation x squared minus four equals zero. Show all of your work "
"step by step and explain your reasoning so that a beginner can follow it easily now."
)
keep = MinHashDeduplicator(threshold=0.7).dedup([base, near], show_progress=False)
assert len(keep) == 1
def test_distinct_items_below_threshold_both_kept():
keep = MinHashDeduplicator(threshold=0.95).dedup(
[
"explain how photosynthesis works in plants",
"describe the process of cellular respiration in animals",
],
show_progress=False,
)
assert len(keep) == 2
# ββ Cluster of three βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_cluster_of_three_collapses_to_one():
base = "the quick brown fox jumps over the lazy dog under a bright moon at night"
near1 = base + " quietly"
near2 = base + " softly"
keep = MinHashDeduplicator(threshold=0.7).dedup([base, near1, near2], show_progress=False)
assert len(keep) == 1
# ββ LSH candidate verification (sub-threshold false positives) βββββββββββββββ
def test_distinct_prompts_sharing_long_boilerplate_block_not_merged():
"""Distinct questions that share a long fixed boilerplate block sit just
below the threshold (~0.65) pairwise. LSH may surface them as candidates;
without verifying the candidate's actual Jaccard, union-find transitively
collapses them. Each distinct question must survive as its own record.
"""
suffix = (
"\n\nA) If both assertion and reason are true and reason is the correct "
"explanation of assertion\n"
"B) If both assertion and reason are true but reason is not the correct "
"explanation of assertion\n"
"C) If assertion is true but reason is false\n"
"D) If both assertion and reason are false"
)
bodies = [
"F is more electronegative than Cl F has high electron affinity than Cl.",
"Leucoplasts perform photosynthesis Chloroplasts store fats, starch and proteins",
"CN ion is an ambient nucleophile. Nucleophiles are electron rich species.",
"Fructose is a reducing sugar. It has a ketonic group.",
"The lactic acid shows the geometrical isomerism. Lactic acid has a double bond.",
]
prompts = [b + suffix for b in bodies]
keep = MinHashDeduplicator(threshold=0.8).dedup(prompts, show_progress=False)
assert len(keep) == len(prompts)
def test_true_duplicates_still_merge_after_verification():
"""Verification must not suppress genuine near-duplicates: same question,
trivial rewording, sharing the same boilerplate block -> still one record.
"""
suffix = (
"\n\nAnswer Choices:\n(A) 0.05 uC\n(B) 0.5 uC\n(C) 5 uC\n(D) 50 uC\n"
"(E) 500 uC\n(F) 5000 uC\n(G) 0.005 uC\n(H) 0.0005 uC\n(I) 50 uC\n(J) 5 uC"
)
# Same question, one-word rewording (the->this) -> exact Jaccard ~0.83,
# above 0.8 even after verification; the shared boilerplate doesn't prevent it.
base = "A capacitor with a capacitance of 5 microfarads is charged to a voltage of 10 volts. What is the charge stored on the capacitor?"
near = "A capacitor with a capacitance of 5 microfarads is charged to a voltage of 10 volts. What is the charge stored on this capacitor?"
keep = MinHashDeduplicator(threshold=0.8).dedup([base + suffix, near + suffix], show_progress=False)
assert len(keep) == 1
# ββ Representative selection via key_fn ββββββββββββββββββββββββββββββββββββββ
def test_key_fn_selects_longest_response_proxy():
text = "Solve the equation x squared minus four equals zero showing every step"
response_lengths = [10, 500, 50]
key_fn = lambda i: -response_lengths[i] # longest wins
keep = MinHashDeduplicator().dedup([text, text, text], key_fn=key_fn, show_progress=False)
assert keep == [1]
# ββ Normalization ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_normalization_collapses_case_and_whitespace_variants():
a = "Solve the equation x squared minus four equals zero showing every step"
b = " solve THE equation x squared minus four equals zero showing every step "
keep = MinHashDeduplicator(normalize=True, threshold=0.7).dedup([a, b], show_progress=False)
assert len(keep) == 1
def test_normalization_off_keeps_case_variants():
"""Without normalization, formatting variants may not cross threshold."""
a = "ALPHA BETA GAMMA DELTA EPSILON"
b = "alpha beta gamma delta epsilon"
# With small inputs and high threshold, expect both kept when normalize=False
# (different shingles -> Jaccard 0)
dedup = MinHashDeduplicator(normalize=False, threshold=0.9, shingle_size=2)
keep = dedup.dedup([a, b], show_progress=False)
assert len(keep) == 2
# ββ Shingle kinds ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_char_shingles_work():
a = "abcdefghijklmnopqrstuvwxyz" * 3
b = "abcdefghijklmnopqrstuvwxyz" * 3 + "ZZZ"
keep = MinHashDeduplicator(shingle_kind="char", shingle_size=8, threshold=0.7).dedup(
[a, b], show_progress=False
)
assert len(keep) == 1
def test_invalid_shingle_kind_raises():
with pytest.raises(ValueError):
MinHashDeduplicator(shingle_kind="bogus")
def test_invalid_threshold_raises():
with pytest.raises(ValueError):
MinHashDeduplicator(threshold=1.5)
# ββ Determinism ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_determinism_same_seed_same_result():
texts = ["alpha beta gamma delta epsilon"] * 5 + ["unrelated content here please"]
a = MinHashDeduplicator(seed=7).dedup(texts, show_progress=False)
b = MinHashDeduplicator(seed=7).dedup(texts, show_progress=False)
assert a == b
|