File size: 9,917 Bytes
201dc77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
"""Decontamination heuristics: behaviour on hand-built pairs (renamed binders, reordered hypotheses, numeral
types, unrelated statements). The expectations here ARE the measured false-positive / false-negative profile."""
from __future__ import annotations

import numpy as np

from align.data.decontam import (
    ContaminationLedger, Decontaminator, EvalItem, Hit, LSHIndex, MinHasher, Params, lean_tokens, nl_tokens,
    ngram_set, shingles, text_hash,
)

H = "import Mathlib\nimport Aesop\n\nset_option maxHeartbeats 0\n\nopen BigOperators Real Nat Topology Rat\n\n"

BASE = H + """/-- Let x and y be reals with x + y = 10 and x - y = 2. Prove that the product x * y equals 24 given that both
are positive and their sum of squares is 52. -/
theorem base_problem (x y : ℝ) (h₀ : 0 < x ∧ 0 < y) (h₁ : x + y = 10) (h₂ : x - y = 2) (h₃ : x ^ 2 + y ^ 2 = 52) :
  x * y = 24 := by sorry"""
import re as _re


def _rename(src, mp):
    return _re.sub(r"(?<![\w'])(" + "|".join(_re.escape(k) for k in mp) + r")(?![\w'])", lambda m: mp[m.group(0)], src)


RENAMED = _rename(BASE, {"base_problem": "other_name", "x": "a", "y": "b", "h₀": "p", "h₁": "q", "h₂": "r", "h₃": "s"})
EVAL_OTHER = H + "theorem other_eval (n : ℕ) (hn : 3 < n) (hm : n < 100) (hp : n.Prime) : ¬ (n % 6 = 0) := by sorry"
REORDERED = H + """theorem base_problem (x y : ℝ) (h₃ : x ^ 2 + y ^ 2 = 52) (h₂ : x - y = 2) (h₁ : x + y = 10) (h₀ : 0 < x ∧ 0 < y) :
  x * y = 24 := by sorry"""
NUMERAL = BASE.replace("(x y : ℝ)", "(x y : ℕ)").replace("= 24", "= (24 : ℕ)")
CHANGED_CONST = BASE.replace("= 52", "= 58").replace("= 24", "= 21")
UNRELATED = H + """theorem unrelated (n : ℕ) (hn : 0 < n) (h : n % 7 = 3) : (n ^ 3 + 2 * n + 5) % 7 = 3 := by sorry"""
SHORT_A = H + "theorem a (x : ℝ) : x = x := by sorry"
SHORT_B = H + "theorem b (y : ℝ) : y = y := by sorry"


def dec(statement=BASE, informal=None, **kw):
    return Decontaminator([EvalItem("E", "evalset", statement, informal)], Params(**kw) if kw else None)


def stages(d, stmt, informal=None):
    return {h.stage for h in d.check(stmt, informal)}


# ---------------------------------------------------------------- minhash

def test_minhash_estimates_jaccard():
    mh = MinHasher(num_perm=256, seed=3)
    a = {f"s{i}" for i in range(200)}
    b = {f"s{i}" for i in range(100, 300)}  # |A∩B|=100, |A∪B|=300, J=1/3
    sa = mh.signature(np.array([hash_(x) for x in a], dtype=np.uint64))
    sb = mh.signature(np.array([hash_(x) for x in b], dtype=np.uint64))
    assert abs(MinHasher.jaccard(sa, sb) - 1 / 3) < 0.09
    assert MinHasher.jaccard(sa, sa) == 1.0


def hash_(s):
    import zlib
    return zlib.crc32(s.encode())


def test_lsh_recalls_similar_not_disjoint():
    mh = MinHasher()
    idx = LSHIndex(mh, bands=32, threshold=0.5)
    base = [f"t{i}" for i in range(60)]
    idx.add("base", mh.signature(shingles(base, 3)))
    near = base[:55] + ["x1", "x2", "x3", "x4", "x5"]
    far = [f"z{i}" for i in range(60)]
    assert [i for i, _ in idx.query(mh.signature(shingles(near, 3)))] == ["base"]
    assert idx.query(mh.signature(shingles(far, 3))) == []
    assert idx.query(None) == []


def test_signature_is_deterministic_across_instances():
    a = MinHasher(seed=1).signature(shingles(list("abcdefgh"), 3))
    b = MinHasher(seed=1).signature(shingles(list("abcdefgh"), 3))
    assert (a == b).all()


# ---------------------------------------------------------------- statement stages: measured profile

def test_identical_hits_every_exact_stage():
    s = stages(dec(), BASE)
    assert {"text_hash", "sig_sha", "alpha_sha", "minhash_lsh", "ngram_lean"} <= s


def test_renamed_theorem_name_and_comment_only_is_text_hash_hit():
    s = stages(dec(), BASE.replace("base_problem", "renamed_thm").replace("Let x and y", "Suppose x and y"))
    assert "text_hash" in s and "sig_sha" in s


def test_renamed_binders_caught_by_alpha_not_by_exact_hashes():
    s = stages(dec(), RENAMED)
    assert "alpha_sha" in s
    assert "sig_sha" not in s and "text_hash" not in s  # exact stages are blind to renaming (by design)
    assert "minhash_lsh" in s  # alpha-renamed tokens make the fuzzy stages invariant too


def test_reordered_hypotheses_missed_by_alpha_caught_by_fuzzy():
    s = stages(dec(), REORDERED)
    assert "alpha_sha" not in s and "text_hash" not in s  # known false negative of the hash stages
    assert "minhash_lsh" in s or "ngram_lean" in s       # the fuzzy stages recover it


def test_numeral_type_variant_is_flagged_by_fuzzy_stages_but_not_by_hashes():
    s = stages(dec(), NUMERAL)
    assert not ({"text_hash", "sig_sha", "alpha_sha"} & s)   # different statement: exact stages correctly differ
    assert "minhash_lsh" in s or "ngram_lean" in s           # over-flagged on purpose (documented false positive)


def test_changed_constants_not_flagged_exactly():
    s = stages(dec(), CHANGED_CONST)
    assert not ({"text_hash", "sig_sha", "alpha_sha"} & s)


def test_unrelated_statement_no_hit_at_all():
    assert dec().check(UNRELATED) == []


def test_short_generic_statements_collide_only_in_alpha_hash():
    """`x = x` vs `y = y` is the same problem up to renaming (alpha_sha says so, correctly); the fuzzy stages are
    disabled below `min_lean_tokens` precisely because tiny statements all look alike."""
    d = dec(SHORT_A)
    s = stages(d, SHORT_B)
    assert "alpha_sha" in s
    assert "minhash_lsh" not in s and "ngram_lean" not in s


def test_alpha_sha_conservative_false_positive_documented():
    a = H + "theorem a (x : ℝ) (h : Real.sin x = 1) : x ≠ 0 := by sorry"
    b = H + "theorem b (y : ℝ) (h : Real.cos y = 1) : y ≠ 0 := by sorry"
    # p2_common keeps `sin`/`cos` lowercase-dotted as distinct tokens: the dotted names are NOT renamed
    assert "alpha_sha" not in stages(dec(a), b)


def test_lean_tokens_alpha_invariance():
    assert lean_tokens(BASE) == lean_tokens(RENAMED)
    assert lean_tokens(BASE, alpha=False) != lean_tokens(RENAMED, alpha=False)


def test_text_hash_ignores_comments_header_name():
    assert text_hash(BASE) == text_hash(BASE.replace("base_problem", "zzz").replace("Let x", "Take x"))
    assert text_hash(BASE) != text_hash(CHANGED_CONST)


# ---------------------------------------------------------------- informal text

INFORMAL = ("Let x and y be real numbers such that x plus y equals ten and x minus y equals two, "
            "find the product of x and y when both are positive and their squares sum to fifty two.")


def test_informal_ngram_catches_embedded_copy_and_hash_catches_exact():
    d = dec(EVAL_OTHER, INFORMAL)
    cand_informal = "Problem 7. " + INFORMAL + " Show your work."
    s = stages(d, UNRELATED.replace("unrelated", "q"), cand_informal)
    assert "ngram_nl" in s
    assert "informal_hash" in stages(d, UNRELATED.replace("unrelated", "q"), INFORMAL)


def test_informal_paraphrase_below_ngram_is_missed_known_false_negative():
    d = dec(EVAL_OTHER, INFORMAL)
    para = "Two positive reals have sum 10 and difference 2; compute their product given the sum of squares is 52."
    assert "ngram_nl" not in stages(d, UNRELATED.replace("unrelated", "q"), para)


def test_informal_unrelated_no_hit():
    d = dec(EVAL_OTHER, INFORMAL)
    assert stages(d, UNRELATED.replace("unrelated", "q"), "How many ways can five people sit around a circular table?") == set()


def test_docstring_of_eval_statement_counts_as_informal():
    d = dec(BASE, None)  # BASE's docstring is the informal text
    cand = "Let x and y be reals with x + y = 10 and x - y = 2. Prove that the product x * y equals 24 given that both"
    assert "ngram_nl" in stages(d, UNRELATED.replace("unrelated", "q"), cand)


def test_ngram_nl_ignores_formula_streams_and_stock_phrases():
    d = dec(EVAL_OTHER, "Show that there exists a positive integer n such that a 2 b 2 b 2 c 2 c 2 d 2 holds for all values.")
    cand = UNRELATED.replace("unrelated", "q")
    assert "ngram_nl" not in stages(d, cand, "Prove: show that there exists a positive integer n such that the claim is trivial")
    assert "ngram_nl" not in stages(d, cand, "We expand a 2 b 2 b 2 c 2 c 2 d 2 by hand in this example")


def test_nl_helpers():
    assert nl_tokens("A, b-C 12!") == ["a", "b", "c", "12"]
    assert len(ngram_set(list("abcdef"), 5)) == 2 and ngram_set(list("abc"), 5) == set()


# ---------------------------------------------------------------- elab hash + ancestry

def test_elab_hash_stage():
    d = Decontaminator([EvalItem("E", "s", UNRELATED, elab_sha="deadbeef")])
    assert [h.stage for h in d.check("theorem z : True := by sorry", elab_sha="deadbeef")] == ["elab_hash"]
    assert d.check("theorem z : True := by sorry", elab_sha="other") == []


def test_ancestry_child_inherits_parent_contamination():
    led = ContaminationLedger()
    led.add("parent", [Hit("alpha_sha", "E")], {"root": "parent", "parents": []})
    led.add("child", [], {"root": "parent", "parents": ["parent"]})
    led.add("grandchild", [], {"root": "parent", "parents": ["child"]})
    led.add("other", [], {"root": "other", "parents": []})
    assert led.resolve("child") == (True, "inherited:parent")
    assert led.resolve("grandchild")[0] is True
    assert led.resolve("other") == (False, "clean")
    assert led.resolve("parent") == (True, "direct:alpha_sha")


def test_ancestry_unknown_parent_is_reported_not_assumed_clean():
    led = ContaminationLedger()
    led.add("child", [], {"root": "ghost", "parents": ["ghost"]})
    assert led.resolve("child") == (False, "unknown_parent")
    led2 = ContaminationLedger(known={"ghost": True})
    led2.add("child", [], {"root": "ghost", "parents": ["ghost"]})
    assert led2.resolve("child")[0] is True


def test_ancestry_cycle_terminates():
    led = ContaminationLedger()
    led.add("a", [], {"parents": ["b"]})
    led.add("b", [], {"parents": ["a"]})
    assert led.resolve("a")[0] is False