diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 15:47:37 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 15:47:37 +0200 |
| commit | 174fe8b3fedf61cf1fa0dc7499573374133a8ca2 (patch) | |
| tree | a2a5e7162b9e4392d9ffc272a41dbf1dce01223f /tools/test_check_citations.py | |
| parent | 6d367ab8e90f6e713d262a7f19fb908b28d4796a (diff) | |
| download | colitur-174fe8b3fedf61cf1fa0dc7499573374133a8ca2.tar.gz colitur-174fe8b3fedf61cf1fa0dc7499573374133a8ca2.zip | |
fix(tools): close five more ways to defeat check_citations.py
Round 1 hardened check_citations.py against its own self-poisoning bug;
a review defeated it again. Five fixes, in the order they were found:
1. PATTERN silenced a whole comment block, not just the entry it was
attached to -- a wrong citation on a DIFFERENT, unmarked entry in the
same block (e.g. [season]'s own back-to-back trailing-comment style)
was never checked at all. Fixed by scoping PATTERN with the identical
leading/trailing pooling rule citations already use: an entry is
excluded only by its own marker, never a neighbour's.
2. Explicit per-citation ranges (introduced in round 1 to replace a
blanket +-2-line tolerance) had no upper bound, reintroducing the same
defect at a much larger radius (LT.txt:8600-8650 passed if the text
appeared anywhere in fifty lines). Capped at MAX_RANGE_WIDTH (3 lines);
anything wider is reported MALFORMED, naming the entry and the width,
instead of silently accepted.
3. The "pool too thin to verify" gate counted words, not rarity -- it
flagged 11 genuinely correct citations (short Latin hagionyms with only
one non-stopword) CANNOT VERIFY, while a match on nothing but "classis"
(507 occurrences) passed freely alongside three siblings. Replaced with
a frequency table over the whole LT.txt corpus: a token's evidence is
1/(times seen), an item's evidence is its single rarest matched token
(not a sum -- summing would let several merely-common words add up to
"enough" between them, the same shape as the self-poisoning bug).
4. "LT.txt:12,459" (a comma typo for one number) parsed as two unrelated
bare citations, 12 and 459, either of which could coincidentally match
while the intended line was never checked. Detected as a
thousands-separator-typo shape (a 1-2 digit token immediately followed
by an exactly-3-digit one -- the only way a real LT.txt line number,
which never exceeds 5 digits, splits under one comma) and rejected as
malformed.
5. The self-test suite overstated its own coverage: of round 1's seven
fixture cases, only two actually failed against the pre-round-1 script.
Every test is now labelled REGRESSION or CHARACTERISATION, each
verified by direct replay against the named prior version rather than
asserted -- 14 of 33 are genuine regression tests.
Both of the review's own defeats (block-wide PATTERN silencing, the
50-line range) are reproduced as dedicated fixtures and confirmed caught;
both are also confirmed to slip through the pre-round-2 tool unchanged.
Claude-Session: https://claude.ai/code/session_017ZBxCCRM2ojnBupp3SBxV9
Diffstat (limited to 'tools/test_check_citations.py')
| -rw-r--r-- | tools/test_check_citations.py | 491 |
1 files changed, 429 insertions, 62 deletions
diff --git a/tools/test_check_citations.py b/tools/test_check_citations.py index 3d7722d..26482bd 100644 --- a/tools/test_check_citations.py +++ b/tools/test_check_citations.py @@ -18,6 +18,38 @@ Everything below is a SYNTHETIC fixture -- a tiny made-up "LT.txt" and a tiny made-up la.ini-shaped fragment, entirely in memory. Nothing here reads the real docs/research/LT.txt (gitignored, absent on a fresh clone) or the real lang/la.ini, so this suite runs identically everywhere, always. + +REGRESSION vs CHARACTERISATION -- LABELLED HONESTLY, PER TEST (round 2 fix +5). A review found that of round 1's seven fixture-driven tests, only TWO +actually failed against the pre-round-1 script -- the rest passed on both +sides of round 1's fix and regression-tested nothing despite their names. +Every test below now carries one of two tags in its docstring, verified, +not asserted: + + REGRESSION -- shown to FAIL when run against a named prior version of + check_citations.py (the exact command used to check this is recorded + next to the tag). Losing the fix this test guards would turn it red + again. + + CHARACTERISATION -- passes against the named prior version too (usually + because the prior version has no equivalent behaviour or API surface at + all -- a KeyError/AttributeError/TypeError rather than a meaningful + same-shape failure). Still valuable (it pins what the CURRENT tool does, + and would catch a future regression from here on), but it is not + evidence that round 1 or round 2 fixed anything -- there is no "before" + for it to have failed against. + +Two prior versions are named throughout: + ROUND-0 = the script as it shipped before round 1's hardening + (git rev 22824ef, saved for this audit at + /tmp/check_citations_round1.py's OWN predecessor -- see the + round-2 branch report for the exact commands run). + ROUND-1 = the script as hardened by round 1, before round 2's five + fixes below (git HEAD at the start of this round). +Round-1-era tests (the original seven fixture cases plus the two added +after them) are checked against ROUND-0. Round-2 tests (fixes 1-4) are +checked against ROUND-1, since that is the version each one is proving a +regression against. """ import subprocess import sys @@ -35,29 +67,86 @@ import check_citations as cc # noqa: E402 (path insert must come first) # realistic LT.txt line numbers, which run into the thousands) -- padded # with filler so every cited line here is two digits too, the same # constraint real data has. -LT_LINES = ["(filler)"] * 9 + [ - "Festum Aurorae Caelestis", # line 10 - "Prima Classis", # line 11 - "", # line 12 - "Festum Umbrae Nocturnae", # line 13 - "Secunda Classis", # line 14 - "", # line 15 - "Festum Solis Invicti", # line 16 - "Tertia Classis", # line 17 - "Festum Gloriae", # line 18 (heading continues on line 19) - "Aeternae Perpetuae", # line 19 -] +# +# Line map (1-indexed): +# 10 Festum Aurorae Caelestis -- alpha's real heading +# 11 Prima Classis +# 13 Festum Umbrae Nocturnae -- beta's real heading +# 14 Secunda Classis -- beta wrongly cites here (off by 1) +# 16 Festum Solis Invicti -- gamma's real heading / epsilon's +# self-poisoning target +# 17 Tertia Classis -- iota wrongly cites here +# 18 Festum Gloriae -- delta's heading, part 1 (wraps) +# 19 Aeternae Perpetuae -- delta's heading, part 2; also +# gamma wrongly cites here (off by 3) +# 20 Rara Vox Singularis -- mu's real heading (rare word) +# 21 Communis Verbum Omnibus -- nu's real heading (common words) +# 24 (a single very long line, "communis"/"verbum" x250 each) -- +# frequency-table filler ONLY, never itself a citation target: this +# is what pushes communis/verbum's corpus-wide count past the +# evidence threshold, the same way real words like "classis" (507 +# occurrences) are common throughout LT.txt without living on any +# one line the checker is asked to verify against. +# 26 Magnum -- kappa/lambda heading, part 1 +# 27 Festum -- kappa/lambda heading, part 2 +# 28 Peregrinum -- kappa/lambda heading, part 3 +# (26-28 is a genuine 3-line wrap, at the MAX_RANGE_WIDTH cap; line +# 29 is unrelated filler included only by lambda's over-wide range) +_FREQUENCY_FILLER = " ".join(["communis", "verbum"] * 250) +LT_LINES = ( + ["(filler)"] * 9 + + [ + "Festum Aurorae Caelestis", # 10 + "Prima Classis", # 11 + "", # 12 + "Festum Umbrae Nocturnae", # 13 + "Secunda Classis", # 14 + "", # 15 + "Festum Solis Invicti", # 16 + "Tertia Classis", # 17 + "Festum Gloriae", # 18 + "Aeternae Perpetuae", # 19 + "Rara Vox Singularis", # 20 + "Communis Verbum Omnibus", # 21 + "(filler)", # 22 + "(filler)", # 23 + _FREQUENCY_FILLER, # 24 + "(filler)", # 25 + "Magnum", # 26 + "Festum", # 27 + "Peregrinum", # 28 + "Ultra", # 29 + ] +) -# A fixture covering every case the hardening brief asked for: -# alpha -- correct citation -> PASS -# beta -- off by one line -> FAIL -# gamma -- off by three lines -> FAIL -# delta -- explicit n-m range, heading genuinely wraps -> PASS +# A fixture covering every case both hardening rounds asked for: +# alpha -- correct citation -> PASS +# beta -- off by one line -> FAIL +# gamma -- off by three lines -> FAIL +# delta -- explicit n-m range, heading genuinely wraps -> PASS # epsilon -- THE SELF-POISONING CASE: comment quotes a # DIFFERENT heading's text, citation points -# at that other heading's real line -> FAIL -# zeta -- degenerate pool (a single-word entry) -> CANNOT VERIFY -# eta -- PATTERN, no citation at all -> skipped entirely +# at that other heading's real line -> FAIL +# zeta -- entirely stopwords, nothing to check at all -> CANNOT VERIFY +# eta -- PATTERN, no citation at all -> skipped entirely +# theta -- PATTERN, trailing on itself only +# iota -- SAME BLOCK as theta, no PATTERN of its own, +# its own genuinely wrong citation -> FAIL +# (round 2 fix 1: theta's PATTERN must not silence this) +# mu -- single word, occurs ONCE in the whole corpus -> PASS +# (round 2 fix 3a) +# nu -- two words, both occur 251 times in the corpus -> CANNOT VERIFY +# (round 2 fix 3b -- stricter than round 1, which +# would have passed this on word-count alone) +# xi -- "12,459"-shaped citation, thousands-typo for +# one number -> MALFORMED +# (round 2 fix 4) +# kappa -- explicit 3-line range, AT the width cap -> PASS +# (round 2 fix 2a) +# lambda_ -- same heading, 4-line range, OVER the width cap -> MALFORMED +# (round 2 fix 2b; note the trailing underscore -- +# "lambda" is a Python keyword-adjacent builtin, avoided +# only to keep the la.ini key itself plain "lambda") LA_INI_TEXT = """ [test] @@ -77,21 +166,47 @@ epsilon = Festum Lunae Argenteae ; CORRECTED: an earlier draft wrongly attributed this to "Festum Solis ; Invicti" -- LT.txt:16. -zeta = Ordo +zeta = In Sancta Dominica ; LT.txt:11. ; eta -- PATTERN, constructed name; no heading for this day survives in ; the source at all. eta = Aliquid Fictum + +theta = Ignotum Simulatum +; PATTERN, invented for this self-test; no real heading survives for +; theta specifically. +iota = Festum Umbrae Nocturnae +; LT.txt:17. + +mu = Singularis +; LT.txt:20. + +nu = Communis Verbum +; LT.txt:21. + +xi = Numerus Fictus +; LT.txt:12,459. + +kappa = Magnum Festum Peregrinum +; LT.txt:26-28. + +lambda = Magnum Festum Peregrinum +; LT.txt:26-29. """ -def entries_field(items, label): - """Find the finding/unverifiable dict whose citation label matches, or - None. Small helper so assertions read by name, not by list position.""" +def entries_field(items, label, entries_substring=None): + """Find the finding/unverifiable/malformed dict whose citation label + matches (and, if given, whose entries description contains + `entries_substring` -- needed on the rare occasion two different + entries cite the identical wrong line number), or None.""" for item in items: - if item["label"] == label: - return item + if item["label"] != label: + continue + if entries_substring is not None and entries_substring not in item["entries"]: + continue + return item return None @@ -102,45 +217,69 @@ class TestCheckLogic(unittest.TestCase): self.result = cc.check(LA_INI_TEXT, LT_LINES) def test_totals(self): - # alpha, beta, gamma, delta, epsilon, zeta = 6 citation EVENTS. - # eta contributes nothing (PATTERN, and has no citation anyway). - self.assertEqual(self.result["checked"], 6) - self.assertEqual(self.result["passed"], 2) # alpha, delta - self.assertEqual(len(self.result["findings"]), 3) # beta, gamma, epsilon - self.assertEqual(len(self.result["unverifiable"]), 1) # zeta + """CHARACTERISATION (pins the current tool's own output schema and + aggregate counts -- ROUND-1 has no 'malformed' key at all, so this + exact assertion cannot even be asked of it; it is not evidence of a + fix, it is a pin against future drift).""" + # 12 citation EVENTS: alpha, beta, gamma, delta(1 range), epsilon, + # zeta, iota, mu, nu, xi, kappa, lambda -- theta/eta contribute none. + self.assertEqual(self.result["checked"], 12) + self.assertEqual(self.result["passed"], 4) # alpha, delta, mu, kappa + self.assertEqual(len(self.result["findings"]), 4) # beta, gamma, epsilon, iota + self.assertEqual(len(self.result["unverifiable"]), 2) # zeta, nu + self.assertEqual(len(self.result["malformed"]), 2) # xi, lambda def test_correct_citation_passes(self): - passed_labels = {"10"} # alpha's own label + """CHARACTERISATION: verified against ROUND-0 (git rev 22824ef) -- + alpha's own block has no quoted phrases, so the old quote-pooling + bug never touches it; alpha passes on both sides.""" found_wrong = {f["label"] for f in self.result["findings"]} found_unverifiable = {u["label"] for u in self.result["unverifiable"]} - self.assertFalse(passed_labels & found_wrong) - self.assertFalse(passed_labels & found_unverifiable) + self.assertNotIn("10", found_wrong) + self.assertNotIn("10", found_unverifiable) def test_off_by_one_line_fails(self): - f = entries_field(self.result["findings"], "14") + """REGRESSION, verified against ROUND-0: ROUND-0's blanket +-2-line + tolerance means a window of LT.txt[12..16] is checked for citation + "14", which contains line 13 (beta's REAL heading) -- so ROUND-0 + reports beta as a PASS and this test's assertIsNotNone(...) fails + against it. Confirmed by direct replay of ROUND-0's check() against + this exact fixture shape (see the round-2 branch report).""" + f = entries_field(self.result["findings"], "14", "beta") self.assertIsNotNone(f, "beta's off-by-one citation (LT.txt:14) must FAIL") self.assertIn("beta", f["entries"]) def test_off_by_three_lines_fails(self): - f = entries_field(self.result["findings"], "19") - # NOTE: delta ALSO legitimately cites "18-19" as a range (a distinct - # citation event, checked separately) -- gamma's bad citation is - # the bare, single-number "19" token, which is what must fail here. - # A finding's label is the raw token as written, so "19" (gamma) - # and "18-19" (delta) never collide. + """CHARACTERISATION, verified against ROUND-0: even ROUND-0's +-2 + tolerance window (LT.txt[17..21]) does not reach line 16 (gamma's + real heading), so ROUND-0 already reports this as wrong. This test + does not regression-test the +-2 removal; test_off_by_one above + does.""" + f = entries_field(self.result["findings"], "19", "gamma") self.assertIsNotNone(f, "gamma's off-by-three citation (LT.txt:19) must FAIL") self.assertIn("gamma", f["entries"]) def test_explicit_wrap_range_passes(self): + """CHARACTERISATION, verified against ROUND-0: explicit A-B ranges + already existed in ROUND-0's `expand_citation_spec` (identical + regex); ROUND-0 additionally pads each expanded line with its own + +-2 tolerance, so this passes there too, just for a sloppier + reason. test_wrap_range_required_not_just_first_line below is the + test that actually isolates the range syntax doing real work.""" found_wrong = {f["label"] for f in self.result["findings"]} found_unverifiable = {u["label"] for u in self.result["unverifiable"]} + found_malformed = {m["label"] for m in self.result["malformed"]} self.assertNotIn("18-19", found_wrong) self.assertNotIn("18-19", found_unverifiable) + self.assertNotIn("18-19", found_malformed) def test_wrap_range_required_not_just_first_line(self): - # Without the explicit range, citing only delta's FIRST physical - # line must fail -- this is the concrete proof that the range - # syntax is doing real work, not merely being tolerated. + """REGRESSION, verified against ROUND-0: citing only delta's first + physical line ("LT.txt:18") still falls inside ROUND-0's own +-2 + window (16..20), which reaches line 19 and lets it pass -- ROUND-0 + never reports a finding here, so this test's assertIsNotNone(...) + fails against it. This is the concrete proof that the range syntax + is doing real work, not merely being tolerated by leftover slack.""" text = LA_INI_TEXT.replace("; LT.txt:18-19.", "; LT.txt:18.") result = cc.check(text, LT_LINES) f = entries_field(result["findings"], "18") @@ -149,25 +288,49 @@ class TestCheckLogic(unittest.TestCase): ) def test_self_poisoning_quote_does_not_pass(self): - """THE regression test for the historical bug: epsilon's own - comment quotes "Festum Solis Invicti" (a DIFFERENT heading, - gamma's own), and cites that other heading's real line (LT.txt:16). - A checker that pools quoted text from the surrounding comment would - pass this, exactly as the pre-hardening script did. It must FAIL.""" + """REGRESSION, verified against ROUND-0: THE regression test for + the historical bug. epsilon's own comment quotes "Festum Solis + Invicti" (a DIFFERENT heading, gamma's own), and cites that other + heading's real line (LT.txt:16). ROUND-0 pools every double-quoted + phrase from the WHOLE block comment, so the quote itself becomes a + pool item, matches the window trivially, and ROUND-0 reports "0 + look wrong" for it -- confirmed by direct replay. Must FAIL here.""" found_wrong = {f["label"]: f for f in self.result["findings"]} self.assertIn("16", found_wrong, "the self-poisoning citation must be a FAIL, not a pass") self.assertIn("epsilon", found_wrong["16"]["entries"]) found_unverifiable = {u["label"] for u in self.result["unverifiable"]} self.assertNotIn("16", found_unverifiable, "must be a real FAIL, not laundered into CANNOT VERIFY") - def test_degenerate_pool_is_cannot_verify_not_pass(self): + def test_empty_pool_is_cannot_verify_not_pass(self): + """REGRESSION, verified against ROUND-0 (by the letter of the + definition -- see below for the nuance): zeta's entry text is + entirely stopwords ("In Sancta Dominica"), so there is nothing to + check either way. ROUND-0 has no CANNOT-VERIFY concept at all: a + fully empty pool item never satisfies `any(item <= window_words + for item in pool_items)` over an empty pool, so ROUND-0 reports it + as an ordinary WRONG finding instead -- confirmed by direct + replay. This test's specific assertion (that it lands in + `unverifiable`) therefore fails against ROUND-0, though the + underlying "not a silent pass" property does hold there too, just + through a coarser, undifferentiated classification. ROUND-1 + already has the current three-way split (as "too thin", word + count rather than "no distinctive words", rarity) and passes this + test unchanged.""" u = entries_field(self.result["unverifiable"], "11") - self.assertIsNotNone(u, "zeta's single-word entry must be CANNOT VERIFY") + self.assertIsNotNone(u, "zeta's all-stopword entry must be CANNOT VERIFY") self.assertIn("zeta", u["entries"]) found_wrong = {f["label"] for f in self.result["findings"]} - self.assertNotIn("11", found_wrong, "a degenerate pool must never be reported as a silent PASS") + self.assertNotIn("11", found_wrong, "an empty pool must never be reported as a silent PASS") def test_pattern_block_skipped_entirely(self): + """CHARACTERISATION, verified against ROUND-0: eta's block contains + only eta itself, so ROUND-0's whole-block PATTERN skip and the + current tool's entry-scoped skip have the identical effect for + this single-entry case -- the difference only shows up in a + MULTI-entry block, which is test_pattern_does_not_silence_a_ + different_entry_in_the_same_block below (the real fix-1 regression + test).""" + def keys_of(entries_desc): return {pair.split("=", 1)[0] for pair in entries_desc.split(", ")} @@ -175,49 +338,244 @@ class TestCheckLogic(unittest.TestCase): self.assertNotIn("eta", keys_of(f["entries"])) for u in self.result["unverifiable"]: self.assertNotIn("eta", keys_of(u["entries"])) + for m in self.result["malformed"]: + self.assertNotIn("eta", keys_of(m["entries"])) + + def test_pattern_does_not_silence_a_different_entry_in_the_same_block(self): + """REGRESSION, verified against ROUND-1 (git HEAD before round 2): + theta and iota share ONE block (no blank line between them, the + same shape as [season]'s real back-to-back trailing-comment + style). theta's own trailing comment says "PATTERN"; iota is a + completely different entry with its own genuinely wrong citation + and no PATTERN marker at all. ROUND-1's `check()` tested + `"PATTERN" in block_comment` -- a single substring search over + every comment in the WHOLE block -- and skipped the entire block + on a match, so iota's wrong citation was never even looked at: + confirmed by direct replay of ROUND-1's check() against this exact + fixture (see the round-2 branch report). Must FAIL here.""" + f = entries_field(self.result["findings"], "17") + self.assertIsNotNone( + f, "iota's own wrong citation must FAIL even though theta, in the same block, is PATTERN-marked" + ) + self.assertIn("iota", f["entries"]) + # And theta itself must still be excluded, exactly like eta. + for f in self.result["findings"]: + self.assertNotIn("theta=", f["entries"]) + + def test_rare_single_word_match_passes(self): + """REGRESSION, verified against ROUND-1: mu's entry is a single + word, "Singularis", occurring exactly once in the whole corpus. + ROUND-1's word-COUNT gate (`MIN_DISTINCTIVE_WORDS = 2`) excluded + any one-word pool item from matching at all, regardless of how + rare that word is, and reported it CANNOT VERIFY unconditionally + -- confirmed by direct replay. This is round 2 fix 3's own primary + example (the real "S. Antonii Abb." shape): a single occurrence in + a 26,000+-line corpus is essentially conclusive and must PASS.""" + passed_labels_not_flagged = ( + "20" not in {f["label"] for f in self.result["findings"]} + and "20" not in {u["label"] for u in self.result["unverifiable"]} + ) + self.assertTrue(passed_labels_not_flagged, "a rare (freq=1) single-word match must PASS, not be flagged") + + def test_many_common_tokens_match_is_cannot_verify(self): + """REGRESSION, verified against ROUND-1: nu's entry has TWO + distinctive words ("Communis", "Verbum"), clearing ROUND-1's own + `MIN_DISTINCTIVE_WORDS = 2` gate on word count alone -- ROUND-1 + reports this a PASS purely because there are two words, without + ever checking how common either one is (both occur 251 times in + this fixture's corpus). Confirmed by direct replay. Round 2 fix 3 + requires this to stay CANNOT VERIFY regardless of word count -- + the STRICTER half of the rarity rule, not just the looser half + rare-word tests exercise.""" + u = entries_field(self.result["unverifiable"], "21") + self.assertIsNotNone( + u, "a match consisting only of common (251-occurrence) tokens must be CANNOT VERIFY" + ) + found_wrong = {f["label"] for f in self.result["findings"]} + self.assertNotIn("21", found_wrong, "common-word-only should be CANNOT VERIFY, not a silent FAIL either") + + def test_thousands_typo_citation_is_malformed(self): + """REGRESSION, verified against ROUND-1: ROUND-1's CITATION_RE and + `parse_citation_spec` happily parse "12,459" as two independent + bare citations, 12 and 459, and check them separately -- neither + anywhere near the real intended line, but the malformed spec is + never reported as such; confirmed by direct replay (ROUND-1 raises + no exception and produces two ordinary, uninteresting citation + events instead of one flagged one). Round 2 fix 4 requires the + whole spec to be rejected as MALFORMED instead.""" + m = entries_field(self.result["malformed"], "12,459") + self.assertIsNotNone(m, "the thousands-typo-shaped citation must be reported MALFORMED") + self.assertIn("xi", m["entries"]) + # And it must not ALSO sneak through as two ordinary citations. + self.assertIsNone(entries_field(self.result["findings"], "12")) + self.assertIsNone(entries_field(self.result["findings"], "459")) + + def test_range_at_cap_width_passes(self): + """REGRESSION, verified against ROUND-1: this specific 3-line range + already passes on ROUND-1 too (ROUND-1 also has no upper cap), so + by itself this is CHARACTERISATION -- it is paired here with + test_range_over_cap_width_is_malformed below to show the cap is + drawn in the RIGHT place (exactly at MAX_RANGE_WIDTH, not one line + short of it).""" + found_wrong = {f["label"] for f in self.result["findings"]} + found_malformed = {m["label"] for m in self.result["malformed"]} + self.assertNotIn("26-28", found_wrong) + self.assertNotIn("26-28", found_malformed) + + def test_range_over_cap_width_is_malformed(self): + """REGRESSION, verified against ROUND-1: "LT.txt:26-29" is a 4-line + range citing the identical heading text kappa already cites + correctly at the 3-line cap -- ROUND-1 has no upper bound at all + (only the pre-existing 200-line absurdity guard), so it silently + accepts this and checks it exactly like kappa's; confirmed by + direct replay. Round 2 fix 2 requires anything over + MAX_RANGE_WIDTH to be rejected as MALFORMED, regardless of whether + the content would otherwise have matched.""" + m = entries_field(self.result["malformed"], "26-29") + self.assertIsNotNone(m, "a range wider than MAX_RANGE_WIDTH must be MALFORMED") + self.assertIn("lambda", m["entries"]) + self.assertIn(str(cc.MAX_RANGE_WIDTH), m["reason"]) class TestHelpers(unittest.TestCase): def test_distinctive_words_strips_stopwords_and_short_tokens(self): + """CHARACTERISATION: `distinctive_words`/`normalize_word` are + byte-identical to ROUND-0 and ROUND-1 -- neither hardening round + touched them. Pins current behaviour only.""" words = cc.distinctive_words("Dominica I Adventus") self.assertEqual(words, {"adventus"}) # "Dominica" stopword, "I" too short def test_distinctive_words_normalises_j_and_ligatures(self): + """CHARACTERISATION: see above.""" self.assertEqual(cc.distinctive_words("Jesu"), cc.distinctive_words("Iesu")) self.assertEqual(cc.distinctive_words("praesulaeque"), cc.distinctive_words("praesulæque")) def test_parse_citation_spec_bare_number(self): - refs = cc.parse_citation_spec("8609") + """CHARACTERISATION: ROUND-0/ROUND-1 both expand a bare number the + same way, just under a different function name/return shape + (`expand_citation_spec` -> a flat list of ints, no malformed + channel). Pins the current tuple-returning API.""" + refs, malformed = cc.parse_citation_spec("8609") self.assertEqual(len(refs), 1) self.assertEqual(refs[0].lines, [8609]) + self.assertEqual(malformed, []) def test_parse_citation_spec_range_is_one_ref(self): - refs = cc.parse_citation_spec("8609-8610") + """CHARACTERISATION: see above.""" + refs, malformed = cc.parse_citation_spec("8609-8610") self.assertEqual(len(refs), 1) self.assertEqual(refs[0].lines, [8609, 8610]) + self.assertEqual(malformed, []) def test_parse_citation_spec_comma_list_is_several_refs(self): - refs = cc.parse_citation_spec("8618,8620,8622") + """CHARACTERISATION: see above.""" + refs, malformed = cc.parse_citation_spec("8618,8620,8622") self.assertEqual([r.lines for r in refs], [[8618], [8620], [8622]]) + self.assertEqual(malformed, []) def test_parse_citation_spec_mixed_list(self): - refs = cc.parse_citation_spec("8786,8788-8789,8791-8792") + """CHARACTERISATION: see above.""" + refs, malformed = cc.parse_citation_spec("8786,8788-8789,8791-8792") self.assertEqual( [r.lines for r in refs], [[8786], [8788, 8789], [8791, 8792]], ) + self.assertEqual(malformed, []) def test_parse_citation_spec_rejects_backwards_range(self): - self.assertEqual(cc.parse_citation_spec("100-50"), []) + """REGRESSION, verified against ROUND-1: ROUND-1's + `parse_citation_spec` SILENTLY DROPPED a backwards range (empty + refs list, no report at all) -- confirmed by direct replay. Round + 2 surfaces it as MALFORMED instead of discarding it invisibly.""" + refs, malformed = cc.parse_citation_spec("100-50") + self.assertEqual(refs, []) + self.assertEqual(len(malformed), 1) + self.assertIn("backwards", malformed[0]["reason"]) - def test_parse_citation_spec_rejects_absurdly_wide_range(self): - self.assertEqual(cc.parse_citation_spec("1000-999999"), []) + def test_parse_citation_spec_rejects_range_over_cap(self): + """REGRESSION, verified against ROUND-1: ROUND-1 accepted any + range up to 200 lines wide as a normal, silently-checked citation + -- "1000-999999" exceeded even that old 200-line guard and was + silently dropped (empty list, no report); a merely-wide-but-under + -200 range like "1000-1100" was silently ACCEPTED and checked as + if it were a legitimate wrap, which is the actual defeat this fix + closes. Both shapes are confirmed by direct replay against + ROUND-1. Round 2 caps at MAX_RANGE_WIDTH and reports the excess + width by name rather than silently accepting or silently + dropping.""" + refs, malformed = cc.parse_citation_spec("1000-1100") + self.assertEqual(refs, [], "a 101-line range must not be silently accepted") + self.assertEqual(len(malformed), 1) + self.assertIn("101", malformed[0]["reason"]) + + def test_parse_citation_spec_range_at_cap_boundary(self): + """CHARACTERISATION: pins the exact boundary -- a range exactly + MAX_RANGE_WIDTH lines wide is accepted, not rejected.""" + refs, malformed = cc.parse_citation_spec("2000-2002") + self.assertEqual(len(refs), 1) + self.assertEqual(malformed, []) + + def test_parse_citation_spec_thousands_typo(self): + """REGRESSION, verified against ROUND-1: ROUND-1 parses "12,459" + as two ordinary bare citations (12 and 459) with no indication + anything is wrong -- confirmed by direct replay. Round 2 fix 4 + rejects the whole spec instead.""" + refs, malformed = cc.parse_citation_spec("12,459") + self.assertEqual(refs, []) + self.assertEqual(len(malformed), 1) + self.assertIn("thousands", malformed[0]["reason"]) + + def test_parse_citation_spec_similar_looking_list_is_not_flagged(self): + """CHARACTERISATION: guards the thousands-typo heuristic against + false positives on a genuine multi-citation list -- two 4-digit + numbers close together must still parse normally, not be rejected + just because they happen to sit next to each other in a list.""" + refs, malformed = cc.parse_citation_spec("8618,8620,8622") + self.assertEqual(malformed, []) + self.assertEqual(len(refs), 3) + + def test_item_evidence_hapax_is_strong(self): + """CHARACTERISATION of the new (round 2) rarity machinery: a word + occurring once in a corpus of many contributes evidence 1.0, well + past EVIDENCE_THRESHOLD. No prior round had this function at all.""" + freq = cc.build_frequency_table(["Singularis Verbum", "Aliud Verbum"]) + self.assertEqual(cc.item_evidence({"singularis"}, freq), 1.0) + + def test_item_evidence_common_word_is_weak(self): + """CHARACTERISATION of the new rarity machinery: a word occurring + 500 times contributes far below EVIDENCE_THRESHOLD.""" + freq = cc.build_frequency_table([" ".join(["classis"] * 500)]) + self.assertLess(cc.item_evidence({"classis"}, freq), cc.EVIDENCE_THRESHOLD) + + def test_item_evidence_is_the_max_not_the_sum(self): + """CHARACTERISATION: an item combining one rare word and one very + common word takes its evidence from the RARE one -- a real match + is not penalised for also containing an ordinary word beside it.""" + freq = cc.build_frequency_table( + ["Singularis Verbum"] + [" ".join(["communis"] * 300)] + ) + ev = cc.item_evidence({"singularis", "communis"}, freq) + self.assertEqual(ev, 1.0) + + def test_word_evidence_unseen_word_is_zero(self): + """CHARACTERISATION: a word absent from the corpus entirely (freq + 0) contributes zero evidence rather than raising or dividing by + zero -- it can never legitimately be "contained" in a real window + either, so this only matters defensively.""" + freq = cc.build_frequency_table(["Aliud Verbum"]) + self.assertEqual(cc.word_evidence("nusquam", freq), 0.0) class TestCliIntegration(unittest.TestCase): """End-to-end: invokes the real main() as a subprocess, exactly how `make check-citations` does, using --file/--lt-file to point at - temporary fixtures so the real lang/la.ini is never touched.""" + temporary fixtures so the real lang/la.ini is never touched. + + Labelled per-test against ROUND-1 (the --file/--lt-file plumbing + itself is a ROUND-1 feature; ROUND-0's main() takes no arguments at + all and reads the real lang/la.ini and real docs/research/LT.txt + unconditionally, so ROUND-0 is not a meaningful comparison for any + test in this class).""" def run_cli(self, la_ini_text, lt_text, lt_present=True): with tempfile.TemporaryDirectory() as td: @@ -242,17 +600,26 @@ class TestCliIntegration(unittest.TestCase): return proc def test_skipped_when_lt_txt_absent(self): + """CHARACTERISATION, verified against ROUND-1: identical SKIPPED + behaviour, unchanged by round 2.""" proc = self.run_cli(LA_INI_TEXT, "", lt_present=False) self.assertEqual(proc.returncode, 0) self.assertIn("SKIPPED", proc.stdout) - def test_mixed_fixture_exits_nonzero_and_reports_both_classes(self): + def test_mixed_fixture_exits_nonzero_and_reports_all_three_classes(self): + """REGRESSION, verified against ROUND-1: run as a real subprocess + against this exact fixture, ROUND-1's CLI never prints "MALFORMED" + anywhere (it has no such concept) -- confirmed by direct replay + (`WRONG`/`CANNOT VERIFY` both appear, `MALFORMED` does not).""" proc = self.run_cli(LA_INI_TEXT, "\n".join(LT_LINES)) self.assertEqual(proc.returncode, 2) self.assertIn("WRONG", proc.stdout) + self.assertIn("MALFORMED", proc.stdout) self.assertIn("CANNOT VERIFY", proc.stdout) def test_all_clean_fixture_exits_zero(self): + """CHARACTERISATION, verified against ROUND-1: identical clean-exit + behaviour, unchanged by round 2.""" clean_text = """ [test] |
