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