Skip to content

Commit bebf177

Browse files
committed
Refuse a corpus that has shrunk
The empty-file guard only catches a corpus that lost EVERY name. One truncated to a handful sails past it: the run compares 298 names instead of 751, prints those counts, and exits 0 -- green while having compared a fraction of what its own summary reports. Floors rather than exact counts, because corpus_issues.jsonl grows whenever it is regenerated from the tracker and an exact pin would fail on every legitimate harvest. A corpus with no floor is a hard error rather than an unguarded default, so adding one forces a decision the way the Script tables do -- and the test binds both directions, so neither a floor without a file nor a file without a floor can rot.
1 parent 016fead commit bebf177

2 files changed

Lines changed: 90 additions & 1 deletion

File tree

tests/v2/test_differential.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,8 @@ def test_ambiguities_is_a_legal_field_name() -> None:
325325

326326
def _run_main(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ledger_body: str,
327327
baseline_facade: dict, baseline: str = "1.4.0",
328-
baseline_v2: dict | None = None) -> tuple[int, str]:
328+
baseline_v2: dict | None = None,
329+
floor: int | None = 1) -> tuple[int, str]:
329330
"""Drive main() end to end with a faked baseline worker.
330331
331332
No uv, no network. The helper exists because every unit test above
@@ -351,6 +352,11 @@ def _fake(v: str, w: bool, n: list[str]) -> tuple[dict, list[dict]]:
351352
return ({"__version__": v,
352353
"__file__": "/wheel/nameparser/__init__.py"}, [row])
353354

355+
# The fixture corpus needs a floor like any other. `floor=None`
356+
# leaves it unregistered, for the test that pins what happens when
357+
# a corpus arrives without one.
358+
if floor is not None:
359+
monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, floor)
354360
monkeypatch.setattr(compare, "HERE", tmp_path)
355361
monkeypatch.setattr(compare, "_run_worker", _fake)
356362
monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", baseline,
@@ -636,3 +642,55 @@ def test_main_asks_for_the_facade_alone_below_2_0(
636642
_run_main(tmp_path, monkeypatch,
637643
'[[change]]\nissue = "x"\nname_regex = "ZZZ"\n', _SAME_FACADE)
638644
assert _WORKER_CALL["want_v2"] is False
645+
646+
647+
def test_every_shipped_corpus_has_a_floor_and_clears_it() -> None:
648+
"""Two bindings, so neither half can rot alone: every corpus file
649+
on disk must have a floor, and must be at or above it.
650+
651+
The floor exists because the empty-file guard only catches a corpus
652+
that lost EVERY name. One truncated to a handful passes that guard,
653+
and the run exits 0 having compared a fraction of what its summary
654+
line reports -- the harness's own stated nightmare, reached by a
655+
file that is merely short rather than absent.
656+
"""
657+
import json
658+
corpora = sorted(_TOOLS.glob("corpus*.jsonl"))
659+
assert corpora, "no corpora found; this test would pass vacuously"
660+
for path in corpora:
661+
names = [json.loads(line) for line
662+
in path.read_text(encoding="utf-8").splitlines()
663+
if line.strip()]
664+
floor = compare._CORPUS_FLOORS.get(path.name)
665+
assert floor is not None, (
666+
f"{path.name} has no _CORPUS_FLOORS entry; add one a little "
667+
f"under its {len(names)} names")
668+
assert len(names) >= floor, (
669+
f"{path.name} holds {len(names)}, below its floor {floor}")
670+
671+
672+
def test_a_floor_names_a_corpus_that_exists() -> None:
673+
"""The other direction: a floor for a file nobody ships is a guard
674+
that can never fire, and reads as coverage that is not there."""
675+
on_disk = {p.name for p in _TOOLS.glob("corpus*.jsonl")}
676+
assert set(compare._CORPUS_FLOORS) <= on_disk
677+
678+
679+
def test_main_aborts_on_a_truncated_corpus(
680+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
681+
"""A corpus below its floor must stop the run, not shrink it."""
682+
with pytest.raises(SystemExit, match="below its floor"):
683+
_run_main(tmp_path, monkeypatch,
684+
'[[change]]\nissue = "x"\nname_regex = "ZZZ"\n',
685+
_SAME_FACADE, floor=50)
686+
687+
688+
def test_main_aborts_on_a_corpus_with_no_floor(
689+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
690+
"""Adding a corpus without a floor must be a decision, not a
691+
silent default -- the same force-a-decision shape the Script
692+
tables use."""
693+
with pytest.raises(SystemExit, match="no entry in _CORPUS_FLOORS"):
694+
_run_main(tmp_path, monkeypatch,
695+
'[[change]]\nissue = "x"\nname_regex = "ZZZ"\n',
696+
_SAME_FACADE, floor=None)

tools/differential/compare.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,23 @@ def _is_latin_only(name: str) -> bool:
341341
#: match every name in every corpus.
342342
_SENTINELS = ("John Smith", "田中さん", "Хосе Сантос", "x")
343343

344+
#: Per-corpus size floors. The existing empty-file guard only catches a
345+
#: corpus that lost EVERY name; one truncated to a handful sails past
346+
#: it, and the run then exits 0 having compared a fraction of what its
347+
#: own summary line reports -- green, and quietly meaningless.
348+
#:
349+
#: Floors, not counts, because corpus_issues.jsonl grows whenever it is
350+
#: regenerated from the tracker and pinning it exactly would fail on
351+
#: every legitimate harvest. Set a little under the real size, and
352+
#: ratchet up only deliberately. A file with no entry here is a hard
353+
#: error rather than an unguarded default: the point is to force a
354+
#: decision when a corpus is added, the way the Script tables do.
355+
_CORPUS_FLOORS = {
356+
"corpus.jsonl": 480, # 486 today, from v1's banks at a pinned ref
357+
"corpus_cjk.jsonl": 95, # 97 today, generated from the case table
358+
"corpus_issues.jsonl": 190, # 200 today, harvested and append-only
359+
}
360+
344361

345362
def validate_rules(rules: list[dict[str, object]], ledger: str) -> None:
346363
"""Reject malformed allowlist rules LOUDLY at startup.
@@ -493,6 +510,20 @@ def main() -> int:
493510
for line in path.read_text().splitlines() if line.strip()]
494511
if not names:
495512
raise SystemExit(f"{path.name} is empty; comparison aborted")
513+
floor = _CORPUS_FLOORS.get(path.name)
514+
if floor is None:
515+
raise SystemExit(
516+
f"{path.name} has no entry in _CORPUS_FLOORS. Add one at "
517+
f"a little under its size: without a floor a corpus can "
518+
f"shrink to a handful of names and the run still exits "
519+
f"0, having compared far less than it reports")
520+
if len(names) < floor:
521+
raise SystemExit(
522+
f"{path.name} holds {len(names)} names, below its floor "
523+
f"of {floor} -- it has shrunk or been truncated. The run "
524+
f"would still exit 0 while comparing a fraction of what "
525+
f"it claims. Restore the file, or lower the floor "
526+
f"deliberately if names were removed on purpose")
496527
per_file[path.name] = len(names)
497528
corpus.extend(names)
498529
# dedupe across files, keeping first-seen order stable for output

0 commit comments

Comments
 (0)