summaryrefslogtreecommitdiff
path: root/tools/test_check_citations.py
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 15:47:37 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 15:47:37 +0200
commit174fe8b3fedf61cf1fa0dc7499573374133a8ca2 (patch)
treea2a5e7162b9e4392d9ffc272a41dbf1dce01223f /tools/test_check_citations.py
parent6d367ab8e90f6e713d262a7f19fb908b28d4796a (diff)
downloadcolitur-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.py491
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]