aboutsummaryrefslogtreecommitdiff
path: root/tools/test_check_citations.py
blob: 26482bdc0825f21a0081df4b264435b8f3412ab1 (plain) (blame)
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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
#!/usr/bin/env python3
"""Self-test for check_citations.py.

This is the regression suite the tool itself did not have when the
self-poisoning bug (see check_citations.py's own module docstring) shipped
undetected: the corrective comment documenting a past wrong citation quoted
the wrong historical value, and that quote sat in the same word pool the
checker verified against, so re-introducing the exact bug produced "0 look
wrong" instead of a failure. A verification tool with no tests of its own
is exactly how you get one that passes for the wrong reason -- this file is
the fix for that, not merely for the bug it happened to expose.

Runs under `dune test` via tools/dune's own `(rule (alias runtest) ...)`,
not only as a standalone script or a `make` target, so it cannot rot
unnoticed. Also runnable directly: `python3 tools/test_check_citations.py`.

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
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import check_citations as cc  # noqa: E402  (path insert must come first)

# ---------------------------------------------------------------------------
# Synthetic "LT.txt". 0-indexed list where element 0 IS line 1 (matching
# check_citations.py's own convention: lt_lines[n - 1] is line n). Real
# citations are always >= 2 digits (CITATION_RE requires \d{2,6}, matching
# 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.
#
# 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 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    -- 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]

alpha = Festum Aurorae Caelestis
; LT.txt:10.

beta = Festum Umbrae Nocturnae
; LT.txt:14.

gamma = Festum Solis Invicti
; LT.txt:19.

delta = Festum Gloriae Aeternae
; LT.txt:18-19.

epsilon = Festum Lunae Argenteae
; CORRECTED: an earlier draft wrongly attributed this to "Festum Solis
; Invicti" -- LT.txt:16.

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, 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:
            continue
        if entries_substring is not None and entries_substring not in item["entries"]:
            continue
        return item
    return None


class TestCheckLogic(unittest.TestCase):
    """Unit-level: exercises check() directly against the synthetic fixture."""

    def setUp(self):
        self.result = cc.check(LA_INI_TEXT, LT_LINES)

    def test_totals(self):
        """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):
        """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.assertNotIn("10", found_wrong)
        self.assertNotIn("10", found_unverifiable)

    def test_off_by_one_line_fails(self):
        """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):
        """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):
        """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")
        self.assertIsNotNone(
            f, "citing only the first physical line of a wrapped heading must FAIL"
        )

    def test_self_poisoning_quote_does_not_pass(self):
        """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_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 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, "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(", ")}

        for f in self.result["findings"]:
            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):
        """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):
        """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):
        """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):
        """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):
        """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_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.

    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:
            tdp = Path(td)
            la_ini_path = tdp / "la.ini"
            la_ini_path.write_text(la_ini_text, encoding="utf-8")
            lt_path = tdp / "LT.txt"
            if lt_present:
                lt_path.write_text(lt_text, encoding="utf-8")
            proc = subprocess.run(
                [
                    sys.executable,
                    str(Path(__file__).resolve().parent / "check_citations.py"),
                    "--file",
                    str(la_ini_path),
                    "--lt-file",
                    str(lt_path),
                ],
                capture_output=True,
                text=True,
            )
            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_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]

alpha = Festum Aurorae Caelestis
; LT.txt:10.
"""
        proc = self.run_cli(clean_text, "\n".join(LT_LINES))
        self.assertEqual(proc.returncode, 0)
        self.assertIn("0 look wrong", proc.stdout)


if __name__ == "__main__":
    unittest.main()