diff --git a/AGENTS.md b/AGENTS.md
index 532a8502..1748e5d6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -278,7 +278,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_
**Adding a flag-gated post-parse transform** (reorder/adjust) — add a `Constants` boolean (default `False`), implement a `handle_*()` method, and call it in `post_process()` after `handle_firstnames()` and before `handle_capitalization()`, gated on the flag. Default-off keeps existing parses byte-for-byte unchanged. Two shipped examples: `patronymic_name_order` gates both `handle_east_slavic_patronymic_name_order()` (#85) and `handle_turkic_patronymic_name_order()` (#185) — one flag driving two independent handlers, added in the same `post_process()` slot; `middle_name_as_last` gates `handle_middle_name_as_last()` (#133), which folds `middle_list` into `last_list`.
-**Validating a new parsing rule** — before implementing, simulate it in a throwaway script against `TEST_NAMES` (plus a few target-language examples) to catch regressions/false-positives early. E.g. this surfaced the `last_base` `do`/`st`/`mc` empties and the patronymic `"David Michael Abramovich"` false-reorder. After the change, `tests/v2/test_snapshot.py` lists every corpus name it moved, grouped by changed field -- read them, and re-record with `uv run python tools/differential/snapshot.py --record` once every move is intended, so the PR diff of `snapshot.jsonl` shows the reviewer the same list. `uv run python tools/differential/cldr_agreement.py` reports how the tree's agreement with CLDR's stated name fields moved; `tests/v2/test_cldr_agreement.py` fails on any move, and `tools/differential/cldr_agreement.json` doubles as a measured list of names the parser and CLDR read differently. The same command and test score the GND corpus's real names (`gnd_fields.jsonl`, record `gnd_agreement.json`) by the same rules, and `--record` rewrites every record, printing per source what it accepts. A locale-pack change reads the second view the same command prints, per pack (`cldr_agreement_locales.json`, #672, and `wikidata_native_agreement_locales.json` for real Han and kana names); its `JA` rows run only with the `ja` extra installed. A change to Han, Hangul or kana order or to Korean segmentation reads `wikidata_native_agreement.json` too: the only real names in those scripts the corpora hold. It holds no honorific, so a change to the honorific peel moves nothing there. A change to comma or credential handling also reads the local corpus of hand-typed names (`snapshot.py --local ctgov-officials`, recorded first against the base checkout with `--tree` (a base read once from the same fetch is copied rather than parsed again; `--sample N` reads a fixed sample while iterating; the recorded `snapshot.jsonl` and psc's `agreement.jsonl` are one file per source in the shared cache, so a session measuring in another worktree overwrites your baseline: record it again right before each check, as #752's phase 2f found when ctgov reported a sibling branch's 1,334 joined-credential readings as its own moves); tools/differential/README.md's "Local corpora"): the one population of names nobody chose for a test or a catalog, so it shows how often each shape is typed, and the one no CI run sees. A change to title handling reads `ukec-individuals` the same way: UK donors' names in spoken order and mixed case, in 8,219 of whose 15,214 `parse()` read a title on 2026-10-22. A change to particles or generational words reads `snapshot.py --local tse-donors` the same way, Brazilian names in spoken order dense with both. A change to initials, to a title word outside English, or to how a script's combining marks are read reads `snapshot.py --local nepal-candidates` the same way: Nepal's local election candidates, each a romanized name (surnames written as initials, `B.K.`, `K.C.`; honorific titles such as `Shekh` in front, and `Md.`, which its period makes a title by shape) beside the same name in Devanagari, written with vowel signs and the virama, combining marks the letter-shape tests do not count as letters (decisions/nfd-shapes), and with the joiners ZWJ and ZWNJ. It is the only large population of South Asian names the corpora hold, and its corpus reads both scripts. A change to non-Latin scripts or to Arabic-script names reads `snapshot.py --local jrc-names` the same way, and `local_corpora.py groups jrc-names`: the spellings of one person across scripts and transliterations, harvested from news text, with 17,074 Arabic-script spellings where the committed corpora hold 315 distinct names with an Arabic letter, 96 of them CLDR's fictional renderings (2026-10-24), and under a licence that keeps even a reading of one of its names in the cache. A change to which field a word lands in also reads `local_agreement.py psc-individuals`, recorded the same way: real names with the four boxes their filers typed them into (UK companies' people with significant control), scored field by field and by nationality. It is the only real fielded population outside the committed fields files, and no CI run sees it either.
+**Validating a new parsing rule** — before implementing, simulate it in a throwaway script against `TEST_NAMES` (plus a few target-language examples) to catch regressions/false-positives early. E.g. this surfaced the `last_base` `do`/`st`/`mc` empties and the patronymic `"David Michael Abramovich"` false-reorder. After the change, `tests/v2/test_snapshot.py` lists every corpus name it moved, grouped by changed field -- read them, and re-record with `uv run python tools/differential/snapshot.py --record` once every move is intended, so the PR diff of `snapshot.jsonl` shows the reviewer the same list. `uv run python tools/differential/cldr_agreement.py` reports how the tree's agreement with CLDR's stated name fields moved; `tests/v2/test_cldr_agreement.py` fails on any move, and `tools/differential/cldr_agreement.json` doubles as a measured list of names the parser and CLDR read differently. The same command and test score the GND corpus's real names (`gnd_fields.jsonl`, record `gnd_agreement.json`) by the same rules, and `--record` rewrites every record, printing per source what it accepts. A locale-pack change reads the second view the same command prints, per pack (`cldr_agreement_locales.json`, #672, and `wikidata_native_agreement_locales.json` for real Han and kana names); its `JA` rows run only with the `ja` extra installed. A change to Han, Hangul or kana order or to Korean segmentation reads `wikidata_native_agreement.json` too: the only real names in those scripts the corpora hold. It holds no honorific, so a change to the honorific peel moves nothing there. A change to comma or credential handling also reads the local corpus of hand-typed names (`snapshot.py --local ctgov-officials --base `, which reads the base from a baseline kept for its package's files and never the shared snapshot beside the corpus (a base read once from the same fetch is copied rather than parsed again; `--sample N` reads a fixed sample while iterating; the shared snapshot beside the corpus is one file per source for every worktree, and #752's phase 2f found ctgov reporting a sibling branch's 1,334 joined-credential readings as its own moves against it); tools/differential/README.md's "Local corpora"): the one population of names nobody chose for a test or a catalog, so it shows how often each shape is typed, and the one no CI run sees. A change to title handling reads `ukec-individuals` the same way: UK donors' names in spoken order and mixed case, in 8,219 of whose 15,214 `parse()` read a title on 2026-10-22. A change to particles or generational words reads `snapshot.py --local tse-donors` the same way, Brazilian names in spoken order dense with both. A change to initials, to a title word outside English, or to how a script's combining marks are read reads `snapshot.py --local nepal-candidates` the same way: Nepal's local election candidates, each a romanized name (surnames written as initials, `B.K.`, `K.C.`; honorific titles such as `Shekh` in front, and `Md.`, which its period makes a title by shape) beside the same name in Devanagari, written with vowel signs and the virama, combining marks the letter-shape tests do not count as letters (decisions/nfd-shapes), and with the joiners ZWJ and ZWNJ. It is the only large population of South Asian names the corpora hold, and its corpus reads both scripts. A change to non-Latin scripts or to Arabic-script names reads `snapshot.py --local jrc-names` the same way, and `local_corpora.py groups jrc-names`: the spellings of one person across scripts and transliterations, harvested from news text, with 17,074 Arabic-script spellings where the committed corpora hold 315 distinct names with an Arabic letter, 96 of them CLDR's fictional renderings (2026-10-24), and under a licence that keeps even a reading of one of its names in the cache. A change to which field a word lands in also reads `local_agreement.py psc-individuals --base ` the same way: real names with the four boxes their filers typed them into (UK companies' people with significant control), scored field by field and by nationality. It is the only real fielded population outside the committed fields files, and no CI run sees it either.
## Gotchas
@@ -306,7 +306,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_
**A group-side reading of a family comma's given part that asks `tail_reading` disagrees with the given part's own walk, and the disagreement is accepted.** `tail_reading` reads the part as the comma-less name reads it, plus the bare salutations rules/H8 adds after a family comma, which a predictor of this part asks for with `salutations=True` (#835); assign's walk over the given part after a family comma reads by its own predicates -- a shape-only numeral VI to X stays a name word there (`Smith, John van der Berg VI` reads middle 'van der Berg VI'), V before a third comma part is read strictly (#144), and P6 attaches a period-marked title-particle (`St.`) the comma-less reading takes as a title. So a bound or a decline group computes with `tail_reading` acts where the walk then reads no title: #623's chain bound (`Smith, John Mc Prof. VI`) and #637's join decline (`Smith, abdul Prof. VI` → middle 'Prof. VI', `Smith, abdul St.` → family 'St. Smith'). Both were recorded rather than narrowed (Derek: garbage input; decisions/H5 and #P5, rules/P5's Accepted clause). Before writing another group-side predictor of the given part, sweep those three shapes and decide, rather than rediscovering the gap in review. And a change to the trailing peel ITSELF reaches the given part through those predictors without touching them: #718's words-to-spare count for a one-letter numeral, asked through `trailing_run_start`, made the chain take the V in `Smith, Freiherr von Berg V` (given 'von Berg V', unreported) while assign's walk, which never counts there (S4: "read without it"), read it as the suffix -- found by review on a grid of family-comma and empty-head shapes behind a title-particle; the predictor now passes `count_numeral=False`. When a change moves what the peel takes, put a family comma with a title-particle head in its grid.
-**A comparison that runs both sides in one tree reports 0 differences, which is what success looks like.** `tools/differential/compare.py` has been hardened against this — it generates its baseline worker into a temp dir, strips `PYTHONPATH` from the child, and aborts unless both the version AND the resolved path check out on each side; its README's three invocation traps are the analysis behind that design, worth reading before changing the harness. **The exposure is ad-hoc comparisons you write yourself**, where the same collapse has a cause the harness cannot disarm for you: **the shell's working directory persists between tool calls**, so a two-tree comparison written as two `cd`s silently runs both halves in whichever tree it landed in. Pin absolute paths, and assert `nameparser.__file__` on both sides the way `compare.py` does. The general rule outlives any particular trap: before believing a null result, prove the harness can report a difference — a clean run and a broken harness are the same output. The proof has to come from the input class the null result is about: #683 moved `tools/name_usage.py` to a POST and checked it with `shehu`, which is ASCII and so cannot show that the server decodes a Cyrillic body. A misdecoded query still runs and matches nothing, so the Cyrillic zeros it was meant to vouch for proved nothing until `Иван` (17,119 given) went through the same path. The converse trap is a mutation that moves nothing: it shows the code is unexercised by your grids, not that it is dead. #638 was filed calling group's chain re-ask dead on a grid with no title-particle head before a particle run and a dotted acronym; the next review found 400 moves (`Smith, Freiherr van Berg X.Y.Z.`) and the issue closed the same day. Before calling code dead, look for the input that reaches it. A CACHE of readings is the same trap one step removed: a wrong key copies one tree's readings in as another's, and the check then reports nothing. #859 first keyed `snapshot.py --local`'s kept baselines by `git rev-parse HEAD:nameparser`, which answers for the enclosing checkout, so a gitignored copy inside one (`build/lib`) took the checkout's key; two reviewers found it. Key a cached reading by what the import reads -- the files on disk, hashed again after the read -- never by what version control says should be there. And a switch that changes no file defeats any file key: #752's spike set its options by environment variable, so after the first run every option copied that run's cached local readings until the variables joined the key (2026-10-24).
+**A comparison that runs both sides in one tree reports 0 differences, which is what success looks like.** `tools/differential/compare.py` has been hardened against this — it generates its baseline worker into a temp dir, strips `PYTHONPATH` from the child, and aborts unless both the version AND the resolved path check out on each side; its README's three invocation traps are the analysis behind that design, worth reading before changing the harness. **The exposure is ad-hoc comparisons you write yourself**, where the same collapse has a cause the harness cannot disarm for you: **the shell's working directory persists between tool calls**, so a two-tree comparison written as two `cd`s silently runs both halves in whichever tree it landed in. Pin absolute paths, and assert `nameparser.__file__` on both sides the way `compare.py` does. The general rule outlives any particular trap: before believing a null result, prove the harness can report a difference — a clean run and a broken harness are the same output. The proof has to come from the input class the null result is about: #683 moved `tools/name_usage.py` to a POST and checked it with `shehu`, which is ASCII and so cannot show that the server decodes a Cyrillic body. A misdecoded query still runs and matches nothing, so the Cyrillic zeros it was meant to vouch for proved nothing until `Иван` (17,119 given) went through the same path. The converse trap is a mutation that moves nothing: it shows the code is unexercised by your grids, not that it is dead. #638 was filed calling group's chain re-ask dead on a grid with no title-particle head before a particle run and a dotted acronym; the next review found 400 moves (`Smith, Freiherr van Berg X.Y.Z.`) and the issue closed the same day. Before calling code dead, look for the input that reaches it. A CACHE of readings is the same trap one step removed: a wrong key copies one tree's readings in as another's, and the check then reports nothing. #859 first keyed `snapshot.py --local`'s kept baselines by `git rev-parse HEAD:nameparser`, which answers for the enclosing checkout, so a gitignored copy inside one (`build/lib`) took the checkout's key; two reviewers found it. Key a cached reading by what the import reads -- the files on disk, hashed again after the read -- never by what version control says should be there. And a switch that changes no file defeats any file key: #752's spike set its options by environment variable, so after the first run every option copied that run's cached local readings until the variables joined the key (2026-10-24). A SHARED file of readings is the same trap with no key at all: a local corpus's `snapshot.jsonl` and `psc-individuals`' `agreement.jsonl` are one file per corpus for every worktree, decided by whoever recorded last, and on 2026-10-25 parallel sessions of #752's phase 2 read a sibling's re-record as their own moves -- the silent case being a sibling's record from a branch holding the same change, which reads as "no moves". Use `--base ` whenever another session may be measuring: it reads the base from a baseline keyed by its package's files and never touches the shared file. A kept reading is published under its key only once it is whole and its tree unchanged: `--base`'s first draft had the agreement child write straight to the kept path, through a temporary name every session shared, so two sessions scoring one base could keep a record cut on a line boundary, which every later session would reuse. Write into a folder of the run's own, read it back against a count, take the digest again, then rename it into place.
**`SUFFIX_ACRONYMS` and `SUFFIX_WORDS` are DISJOINT by construction (since 2026-10-18; an import-time assert from 2026-09-08) — a word has one suffix kind, and its reading is an acronym or a word (`acronym`), so the vocabulary build refuses a word classified twice; the fix is to choose, not to relax the check** — the two branches normalize differently (the word test strips only edge periods, the acronym test strips all of them), so a word in both is matched by two rules and which one fired is unreadable from outside. `esq` was the single overlap and left `SUFFIX_ACRONYMS`, which is what made the assert possible; the whole algebra that had made the overlap safe, and the criterion that decided which set a word belongs to, are in the `suffix-acronym-collisions` entry of `docs/design/decisions/` if anyone wants them.
diff --git a/tests/v2/_differential_fixtures.py b/tests/v2/_differential_fixtures.py
index 8ec705f1..38529b5a 100644
--- a/tests/v2/_differential_fixtures.py
+++ b/tests/v2/_differential_fixtures.py
@@ -9,11 +9,13 @@
"""
import importlib.util
import json
+import shutil
import sys
import time
import urllib.request
from pathlib import Path
from types import ModuleType
+from typing import Any
import pytest
@@ -97,13 +99,16 @@ def own_cache(monkeypatch: pytest.MonkeyPatch,
def plant_corpus(monkeypatch: pytest.MonkeyPatch, corpora: ModuleType, source: str,
- records: list[dict[str, str]], date: str, unit: str) -> None:
+ records: list[dict[str, str]], date: str, unit: str,
+ detail: dict[str, Any] | None = None) -> None:
"""`records` fetched as `source`'s local corpus into the cache, with no
network: through a Source carrying the real one's credit and nothing
- else, so that none of the real source's renew or check applies."""
+ else, so that none of the real source's renew or check applies in
+ this process. A child process loads the corpus under the real
+ Source, whose check reads `detail`, the meta.json detail it pins."""
monkeypatch.setitem(corpora.SOURCES, source, corpora.Source(
corpora.SOURCES[source].credit, unit,
- lambda: corpora.Fetched(list(records), date, {})))
+ lambda: corpora.Fetched(list(records), date, dict(detail or {}))))
corpora.fetch(source)
@@ -125,3 +130,25 @@ def urlopen(request: urllib.request.Request, timeout: int) -> _Response:
return _Response(body)
monkeypatch.setattr(urllib.request, "urlopen", urlopen)
return fetched
+
+
+#: Appended to a copied package's __init__.py, a change every reading
+#: shows: each name is parsed with its words reversed.
+_PLANTED = """
+
+_planted_parse = Parser.parse
+Parser.parse = lambda self, text: _planted_parse(self, " ".join(reversed(text.split())))
+"""
+
+
+def checkout_copy(at: Path, *, planted: bool = False) -> Path:
+ """A checkout at `at` holding a copy of this one's nameparser/
+ package, and `planted`, a change every reading shows: a base tree
+ for --base, which a child process reads, so that a check across a
+ planted copy proves the base was read from its own tree."""
+ shutil.copytree(_TOOLS.parents[1] / "nameparser", at / "nameparser",
+ ignore=shutil.ignore_patterns("__pycache__"))
+ if planted:
+ with open(at / "nameparser" / "__init__.py", "a", encoding="utf-8") as f:
+ f.write(_PLANTED)
+ return at
diff --git a/tests/v2/test_local_agreement.py b/tests/v2/test_local_agreement.py
index b14f6d59..9e4b06ee 100644
--- a/tests/v2/test_local_agreement.py
+++ b/tests/v2/test_local_agreement.py
@@ -8,6 +8,7 @@
another fetch or another scorer, and a corpus damaged under the stream. The names are invented.
"""
import hashlib
+import importlib
import json
import sys
import unicodedata
@@ -17,7 +18,7 @@
from nameparser import Parser
-from ._differential_fixtures import load_tool, own_cache, plant_corpus
+from ._differential_fixtures import checkout_copy, load_tool, own_cache, plant_corpus
agreement = load_tool("local_agreement")
corpora = agreement.local_corpora
@@ -96,7 +97,10 @@ def test_a_title_box_holding_an_unlisted_word_the_parse_reads_otherwise_is_set_a
def _corpus(monkeypatch: pytest.MonkeyPatch, *records: dict[str, str]) -> None:
"""A PSC corpus of `records` fetched into the cache, no network behind it."""
- plant_corpus(monkeypatch, corpora, _PSC, list(records), "2026-10-22", "filed names")
+ # The sample pinned as the real source pins it: a --base child
+ # loads the corpus under the real Source and its check.
+ plant_corpus(monkeypatch, corpora, _PSC, list(records), "2026-10-22", "filed names",
+ {"sample": f"1 in {importlib.import_module('local_psc').PSC_SAMPLE}"})
_ALL = (_AGREES, _DOUBLE_SURNAME, _SUFFIX, _JOB_TITLE, _SHAPE_TITLE)
@@ -160,7 +164,7 @@ def test_a_record_of_another_fetch_is_refused(
agreement.main([_PSC])
path = _own_cache / "local" / _PSC / agreement.RECORD
header = json.loads(path.read_text().splitlines()[0])
- assert set(header) == {"raw_sha256", "scorer", "data_date", "tree"}
+ assert set(header) == {"raw_sha256", "scorer", "data_date", "tree", "entries"}
def test_a_run_with_no_record_names_the_command_that_writes_one(
@@ -298,3 +302,141 @@ def test_a_tree_that_is_not_the_imported_package_is_refused(
# The control: this checkout itself is the tree it imported.
root = agreement.HERE.parents[1]
assert agreement.main([_PSC, "--record", "--tree", str(root)]) == 0
+
+
+# -- --base ----------------------------------------------------------------------
+
+def _kept(cache: Path) -> list[Path]:
+ return sorted((cache / "local" / _PSC / agreement.KEPT).glob("*.jsonl"))
+
+
+def test_a_base_check_reports_a_change_planted_in_the_base_and_none_across_a_copy(
+ monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path,
+ capsys: pytest.CaptureFixture[str]) -> None:
+ # The harness must be able to report a difference: a base scored with
+ # the head's tree by mistake would show no move across a planted one.
+ _corpus(monkeypatch, *_ALL)
+ planted = checkout_copy(tmp_path / "planted", planted=True)
+ assert agreement.main([_PSC, "--base", str(planted)]) == 1
+ out = capsys.readouterr().out
+ assert f"base: {planted.resolve()}, nameparser/ " in out and "scored it now" in out
+ assert "progress: 3 records (" in out and "regression" not in out
+ # The control: an unplanted copy is the head's package byte for byte.
+ copy = checkout_copy(tmp_path / "copy")
+ assert agreement.main([_PSC, "--base", str(copy)]) == 0
+ out = capsys.readouterr().out
+ assert "matches the base" in out and "byte-identical" in out
+ assert len(_kept(_own_cache)) == 2
+
+
+def test_a_base_check_never_touches_the_shared_record(
+ monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None:
+ _corpus(monkeypatch, *_ALL)
+ shared = _own_cache / "local" / _PSC / agreement.RECORD
+ base = checkout_copy(tmp_path / "base", planted=True)
+ assert agreement.main([_PSC, "--base", str(base)]) == 1
+ assert not shared.exists()
+ assert agreement.main([_PSC, "--record"]) == 0
+ shared.write_text(shared.read_text().splitlines()[0] + "\n") # would read as moves
+ before = (shared.stat().st_mtime_ns, shared.read_bytes())
+ assert agreement.main([_PSC, "--base", str(checkout_copy(tmp_path / "copy"))]) == 0
+ assert (shared.stat().st_mtime_ns, shared.read_bytes()) == before
+
+
+def test_a_kept_base_record_is_reused_and_a_damaged_one_scored_again(
+ monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path,
+ capsys: pytest.CaptureFixture[str]) -> None:
+ _corpus(monkeypatch, *_ALL)
+ base = checkout_copy(tmp_path / "base", planted=True)
+ assert agreement.main([_PSC, "--base", str(base)]) == 1
+ [kept] = _kept(_own_cache)
+ recorded = kept.read_bytes()
+ capsys.readouterr()
+ real = agreement._score_base
+
+ def unscored(*args: object) -> None:
+ raise AssertionError("scored the base again")
+ monkeypatch.setattr(agreement, "_score_base", unscored)
+ assert agreement.main([_PSC, "--base", str(base)]) == 1
+ assert "reused the kept record" in capsys.readouterr().out
+ # A record cut short on a line boundary still parses line by line;
+ # its header's count is what refuses it.
+ header = json.loads(recorded.decode().splitlines()[0])
+ uncounted = json.dumps({**header, "entries": None}) + "\n" + "".join(
+ recorded.decode().splitlines(keepends=True)[1:])
+ for damage in ("not a record\n", "".join(recorded.decode().splitlines(keepends=True)[:-1]),
+ uncounted):
+ kept.write_text(damage)
+ with pytest.raises(AssertionError, match="scored the base again"):
+ agreement.main([_PSC, "--base", str(base)])
+ assert "deleted the kept record" in capsys.readouterr().out
+ monkeypatch.setattr(agreement, "_score_base", real)
+ assert agreement.main([_PSC, "--base", str(base)]) == 1
+ assert kept.read_bytes() == recorded
+ assert not list(kept.parent.glob(".base-*")) # the child's scratch is gone
+ monkeypatch.setattr(agreement, "_score_base", unscored)
+
+
+def test_a_base_that_changed_while_it_was_scored_keeps_no_record(
+ monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None:
+ _corpus(monkeypatch, *_ALL)
+ base = checkout_copy(tmp_path / "base", planted=True)
+ # The module base_entries imports by its bare name (local_agreement
+ # put its folder on sys.path), not a second load by path.
+ snapshot = importlib.import_module("snapshot")
+ answers = ["a" * 64, "b" * 64]
+ published: list[bool] = []
+
+ def digest(root: Path) -> str:
+ if not answers:
+ return "c" * 64
+ if len(answers) == 1: # the check after the score: nothing kept yet
+ published.append(bool(_kept(_own_cache)))
+ return answers.pop(0)
+ monkeypatch.setattr(snapshot, "_source_digest", digest)
+ with pytest.raises(SystemExit, match="changed while it was scored"):
+ agreement.main([_PSC, "--base", str(base)])
+ # Written to the kept path before the check, a sibling could have
+ # reused it as the base it was keyed for in between.
+ assert published == [False]
+ assert not _kept(_own_cache)
+ assert not list((_own_cache / "local" / _PSC / agreement.KEPT).glob(".base-*"))
+
+
+def test_a_base_that_is_the_tree_read_or_no_checkout_is_refused(
+ monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None:
+ _corpus(monkeypatch, *_ALL)
+ with pytest.raises(SystemExit, match="is the tree being read"):
+ agreement.main([_PSC, "--base", str(agreement.HERE.parents[1])])
+ with pytest.raises(SystemExit, match="no nameparser package"):
+ agreement.main([_PSC, "--base", str(tmp_path)])
+ assert not _kept(_own_cache)
+
+
+def test_a_base_whose_import_fails_is_refused_with_the_childs_output(
+ monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None:
+ _corpus(monkeypatch, *_ALL)
+ base = checkout_copy(tmp_path / "base")
+ with open(base / "nameparser" / "__init__.py", "a", encoding="utf-8") as f:
+ f.write("\nraise ImportError('planted')\n")
+ with pytest.raises(SystemExit, match="(?s)scoring the base .* failed.*planted"):
+ agreement.main([_PSC, "--base", str(base)])
+ assert not _kept(_own_cache)
+
+
+def test_a_base_check_refuses_to_record(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
+ _corpus(monkeypatch, *_ALL)
+ for extra in (["--record"], ["--out", "x.jsonl"]):
+ with pytest.raises(SystemExit) as raised:
+ agreement.main([_PSC, "--base", ".", *extra])
+ assert raised.value.code == 2 and "--base compares" in capsys.readouterr().err
+
+
+def test_record_writes_where_out_says_and_leaves_the_shared_record(
+ monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None:
+ _corpus(monkeypatch, *_ALL)
+ out = tmp_path / "elsewhere.jsonl"
+ assert agreement.main([_PSC, "--record", "--out", str(out)]) == 0
+ assert agreement.read_record(out, corpora.load(_PSC))
+ assert not (_own_cache / "local" / _PSC / agreement.RECORD).exists()
diff --git a/tests/v2/test_local_corpora.py b/tests/v2/test_local_corpora.py
index 51ad3383..cf4b74d1 100644
--- a/tests/v2/test_local_corpora.py
+++ b/tests/v2/test_local_corpora.py
@@ -34,7 +34,13 @@
import pytest
-from ._differential_fixtures import _Response, load_tool, own_cache, plant_corpus
+from ._differential_fixtures import (
+ _Response,
+ checkout_copy,
+ load_tool,
+ own_cache,
+ plant_corpus,
+)
local = load_tool("local_corpora")
snapshot = load_tool("snapshot")
@@ -4091,6 +4097,164 @@ def test_the_source_digest_follows_a_symlinked_subpackage(tmp_path: Path) -> Non
assert digests[0] != digests[1]
+# -- snapshot.py --local --base -------------------------------------------------
+
+def _shared(folder: Path) -> dict[str, tuple[int, bytes]]:
+ """The shared snapshot's files beside a local corpus, as mtime and
+ bytes, for a check that --base left them alone."""
+ return {p.name: (p.stat().st_mtime_ns, p.read_bytes())
+ for p in sorted(folder.glob("snapshot*"))}
+
+
+def test_a_base_check_reports_a_change_planted_in_the_base_and_none_across_a_copy(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
+ capsys: pytest.CaptureFixture[str]) -> None:
+ # The harness must be able to report a difference: a base read from
+ # the head's tree by mistake would show no move across a planted one.
+ folder = _fetched(monkeypatch, tmp_path)
+ planted = checkout_copy(tmp_path / "planted", planted=True)
+ assert snapshot.main(["--local", _SOURCE, "--base", str(planted)]) == 1
+ out = capsys.readouterr().out
+ assert f"base: {planted.resolve()}, nameparser/ " in out and "recorded it now" in out
+ assert "2 entries differ from the base" in out
+ # The control: an unplanted copy is the head's package byte for byte.
+ copy = checkout_copy(tmp_path / "copy")
+ assert snapshot.main(["--local", _SOURCE, "--base", str(copy)]) == 0
+ out = capsys.readouterr().out
+ assert "matches the base" in out and "byte-identical" in out
+ assert len(_baselines(folder)) == 2
+
+
+def test_a_base_check_never_touches_the_shared_snapshot(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+ folder = _fetched(monkeypatch, tmp_path)
+ base = checkout_copy(tmp_path / "base", planted=True)
+ assert snapshot.main(["--local", _SOURCE, "--base", str(base)]) == 1
+ assert not _shared(folder) # none was there, and none was written
+ assert snapshot.main(["--local", _SOURCE, "--record"]) == 0
+ before = _shared(folder)
+ assert set(before) == {"snapshot.jsonl", "snapshot.jsonl.source.json"}
+ # A shared snapshot that would read as a move, ignored by --base.
+ (folder / "snapshot.jsonl").write_text("")
+ before = _shared(folder)
+ copy = checkout_copy(tmp_path / "copy")
+ assert snapshot.main(["--local", _SOURCE, "--base", str(copy)]) == 0
+ assert snapshot.main(["--local", _SOURCE, "--base", str(copy), "--sample", "2"]) == 0
+ assert _shared(folder) == before
+ assert not list((folder / "baselines").glob(".base-*")) # the child's scratch is gone
+
+
+def test_a_kept_base_is_reused_without_reading_it_again(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
+ capsys: pytest.CaptureFixture[str]) -> None:
+ _fetched(monkeypatch, tmp_path)
+ base = checkout_copy(tmp_path / "base", planted=True)
+ assert snapshot.main(["--local", _SOURCE, "--base", str(base)]) == 1
+ capsys.readouterr()
+
+ def unrecorded(*args: object) -> None:
+ raise AssertionError("recorded the base again")
+ monkeypatch.setattr(snapshot, "_record_base", unrecorded)
+ assert snapshot.main(["--local", _SOURCE, "--base", str(base)]) == 1
+ out = capsys.readouterr().out
+ assert "reused the kept baseline" in out and "2 entries differ" in out
+ # An edit to the base keys it as another package, read again.
+ (base / "nameparser" / "_edited.py").write_text("")
+ with pytest.raises(AssertionError, match="recorded the base again"):
+ snapshot.main(["--local", _SOURCE, "--base", str(base)])
+
+
+def test_a_base_check_composes_with_a_sample(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
+ capsys: pytest.CaptureFixture[str]) -> None:
+ folder = _fetched(monkeypatch, tmp_path, *(f"Ann Lee{n}, MD" for n in range(12)))
+ base = checkout_copy(tmp_path / "base", planted=True)
+ assert snapshot.main(["--local", _SOURCE, "--base", str(base), "--sample", "2"]) == 1
+ out = capsys.readouterr().out
+ held = int(re.search(r"a 1-in-2 sample: (\d+) of 12 entries", out)[1]) # type: ignore[index]
+ assert 0 < held < 12 and f"{held} entries differ from the base" in out
+ [kept] = _baselines(folder)
+ assert kept.name.endswith("-sample-2.jsonl.gz")
+
+
+def test_a_base_that_is_the_tree_read_or_no_checkout_is_refused(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+ _fetched(monkeypatch, tmp_path)
+ with pytest.raises(SystemExit, match="is the tree being read"):
+ snapshot.main(["--local", _SOURCE, "--base", str(snapshot.REPO_ROOT)])
+ other = checkout_copy(tmp_path / "other")
+ with pytest.raises(SystemExit, match="is the tree being read"):
+ snapshot.main(["--local", _SOURCE, "--base", str(other), "--tree", str(other)])
+ with pytest.raises(SystemExit, match="no nameparser package"):
+ snapshot.main(["--local", _SOURCE, "--base", str(tmp_path / "empty")])
+ assert not _baselines(_folder(tmp_path))
+
+
+def test_a_base_whose_import_fails_is_refused_with_the_childs_output(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+ _fetched(monkeypatch, tmp_path)
+ base = checkout_copy(tmp_path / "base")
+ with open(base / "nameparser" / "__init__.py", "a", encoding="utf-8") as f:
+ f.write("\nraise ImportError('planted')\n")
+ with pytest.raises(SystemExit, match="(?s)recording the base .* failed.*planted"):
+ snapshot.main(["--local", _SOURCE, "--base", str(base)])
+ assert not _baselines(_folder(tmp_path))
+
+
+def test_a_base_child_is_started_without_the_import_path_overrides(
+ monkeypatch: pytest.MonkeyPatch) -> None:
+ # A unit check, not an end-to-end one: --tree's sys.path entry already
+ # outranks a PYTHONPATH entry in the child, so no decoy can show the
+ # strip; it is kept as compare.py's worker keeps it, against an
+ # import made before the insert.
+ monkeypatch.setenv("PYTHONPATH", "/decoy")
+ monkeypatch.setenv("PYTHONHOME", "/decoy")
+ env = snapshot.child_env()
+ assert "PYTHONPATH" not in env and "PYTHONHOME" not in env and env
+
+
+def test_a_kept_baseline_another_run_pruned_is_read_as_none_kept(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
+ capsys: pytest.CaptureFixture[str]) -> None:
+ # Pruned between a lookup and the open, it is not damage to delete:
+ # read as damage, its unlink raised on the file already gone.
+ kept = tmp_path / "gone.jsonl.gz"
+ with gzip.open(kept, "wt", encoding="utf-8") as f:
+ f.write("{}\n{}\n")
+ real_open = gzip.open
+
+ def pruned_first(path: Path, mode: str, encoding: str) -> object:
+ Path(path).unlink()
+ return real_open(path, mode, encoding=encoding)
+ monkeypatch.setattr(snapshot.gzip, "open", pruned_first)
+ assert snapshot._reused(kept, 2) is None
+ assert "deleted the kept baseline" not in capsys.readouterr().out
+
+
+def test_a_scratch_folder_a_killed_run_left_is_swept_once_stale(tmp_path: Path) -> None:
+ stale, fresh = snapshot.scratch(tmp_path), snapshot.scratch(tmp_path)
+ (stale / "base.jsonl").write_text("x\n")
+ os.utime(stale, (1_000_000, 1_000_000))
+ snapshot._prune(tmp_path)
+ assert not stale.exists() and fresh.exists()
+
+
+@pytest.mark.parametrize("extra", [["--record"], ["--out", "x.jsonl"]])
+def test_a_base_check_refuses_to_record(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
+ capsys: pytest.CaptureFixture[str], extra: list[str]) -> None:
+ _fetched(monkeypatch, tmp_path)
+ with pytest.raises(SystemExit) as raised:
+ snapshot.main(["--local", _SOURCE, "--base", str(tmp_path), *extra])
+ assert raised.value.code == 2 and "--base compares" in capsys.readouterr().err
+
+
+def test_a_base_check_needs_a_local_corpus(capsys: pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit) as raised:
+ snapshot.main(["--base", "."])
+ assert raised.value.code == 2 and "--base reads a local corpus" in capsys.readouterr().err
+
+
def test_a_baseline_another_run_pruned_is_not_reported_as_unkept(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
capsys: pytest.CaptureFixture[str]) -> None:
diff --git a/tools/differential/README.md b/tools/differential/README.md
index ac47c66c..a4b74788 100644
--- a/tools/differential/README.md
+++ b/tools/differential/README.md
@@ -244,14 +244,21 @@ uv run python tools/name_usage.py --no-wikidata MA FNP-C
uv run python tools/differential/local_agreement.py psc-individuals
```
-To read what a branch moves, record the local snapshot with the base checkout, then check with the branch:
+To read what a branch moves, check it from the branch against the base checkout you name:
+
+```
+uv run python tools/differential/snapshot.py --local ctgov-officials --base ../master-checkout
+uv run python tools/differential/local_agreement.py psc-individuals --base ../master-checkout
+```
+
+`--base` reads the base's readings from the baseline kept for its `nameparser/` (below), recording one in a child process where none is kept, and never reads or writes the shared `snapshot.jsonl` or `agreement.jsonl` beside the corpus. It prints which base it read, by path and digest prefix, and whether the reading was reused or recorded now, and then the head's digest, saying outright when the two packages are byte-identical and so no reading can move. It refuses a base that is the tree being read, or that holds no `nameparser` package, and the child refuses a package imported from anywhere but the base, as `--record --tree` does; a reused baseline was keyed by the base's files on disk, so it needs no import to vouch for it. `--sample N` composes with it. Use it whenever another session may be measuring: the shared snapshot is whoever recorded last, and on 2026-10-25 parallel sessions of #752's phase 2 read each other's re-records as their own moves, the worse case being silent, a sibling's record from a branch holding the same change reading as "no moves". The shared file still works for a session alone, recorded with the base and checked with the branch:
```
uv run python tools/differential/snapshot.py --local ctgov-officials --record --tree ../master-checkout
uv run python tools/differential/snapshot.py --local ctgov-officials
```
-Any snapshot reading of 5,000 entries or more, committed or local, parses in a worker process per CPU (the `pairs` and `groups` views parse in one) (`--jobs N` to change that), and a local reading does not read the facade, whose v1 compatibility is the committed corpora's question. Measured 2026-10-23 on a 12-CPU machine: a 20,000-name sample cost about 120 µs a name for the 2.0 parse and its renders, and the facade about as much again; a check of `fec-individuals` (1,893,964 names) took 49s, and of `ctgov-officials` (352,104) 9.6s, where one process reading the facade too would take about 7.5 minutes and 85s, estimated from that sample's per-name cost. A local recording is kept gzipped in `baselines/` beside the snapshot (41 MB for `fec-individuals` against 249 MB plain, 2026-10-23), under a key of the fetch, a hash of every file in the read tree's `nameparser/` as it is on disk (`__pycache__` aside), the interpreter's minor version and the code that writes a row (`snapshot.py`, `corpus_build.py`, `shapes.py`); recording that base again copies it rather than parsing. The hash is taken again after the read, and a package that changed meanwhile keeps no baseline; a kept baseline that cannot be read, or holds another number of rows than the corpus has entries, is deleted and the names are read. The four most recently recorded or reused baselines are kept per source, whole and sampled alike, so a few `--sample` recordings can push out a whole one. A snapshot recorded before 2026-10-23 may carry facade rows; a check against one sets them aside and says so. `--sample N` reads a fixed 1-in-N sample, chosen by a hash of the name, into `snapshot.sample-N.jsonl`, for iterating on a change; a sample is never compared with a snapshot of the whole corpus or of another sample, and a count quoted from one says it is a sample.
+Any snapshot reading of 5,000 entries or more, committed or local, parses in a worker process per CPU (the `pairs` and `groups` views parse in one) (`--jobs N` to change that), and a local reading does not read the facade, whose v1 compatibility is the committed corpora's question. Measured 2026-10-23 on a 12-CPU machine: a 20,000-name sample cost about 120 µs a name for the 2.0 parse and its renders, and the facade about as much again; a check of `fec-individuals` (1,893,964 names) took 49s, and of `ctgov-officials` (352,104) 9.6s, where one process reading the facade too would take about 7.5 minutes and 85s, estimated from that sample's per-name cost. A local recording is kept gzipped in `baselines/` beside the snapshot (41 MB for `fec-individuals` against 249 MB plain, 2026-10-23), under a key of the fetch, a hash of every file in the read tree's `nameparser/` as it is on disk (`__pycache__` aside), the interpreter's minor version and the code that writes a row (`snapshot.py`, `corpus_build.py`, `shapes.py`); recording that base again copies it rather than parsing. The hash is taken again after the read, and a package that changed meanwhile keeps no baseline; a kept baseline that cannot be read, or holds another number of rows than the corpus has entries, is deleted and the names are read. The ten most recently recorded or reused baselines are kept per source, whole and sampled alike, so a few `--sample` recordings can push out a whole one: room for four sessions measuring at once with a base and a head each (four were kept until 2026-10-25, and parallel sessions evicted each other's bases), about 420 MB for `fec-individuals`, whose baseline is the largest (41.7 MB). A snapshot recorded before 2026-10-23 may carry facade rows; a check against one sets them aside and says so. `--sample N` reads a fixed 1-in-N sample, chosen by a hash of the name, into `snapshot.sample-N.jsonl`, for iterating on a change; a sample is never compared with a snapshot of the whole corpus or of another sample, and a count quoted from one says it is a sample.
`compare.py --corpus` takes the cached `corpus.jsonl` too, against a release.
@@ -272,7 +279,7 @@ A live source has no fixed snapshot to pin, so the cached copy is the pin: `meta
`nepal-candidates` reads each local body's file through the site's own JSON handler, which asks for the site's double-submit check: the `CsrfToken` cookie a GET of the site sets, echoed in an `X-CSRF-Token` header, with the `Referer` its pages send. The site refuses a User-Agent holding `python` or `curl` with a 403 (2026-10-24), so this source sends a browser's form naming the tool (`NEPAL_USER_AGENT`) where the others send the repository's URL. A 403 mid-fetch is what a lapsed token gets, and renews the session once before it is raised; a 404 counts the file as missing, its id kept in `meta.json`'s detail, and with the empty files (a list of no candidates, as a site that blanked its files would answer) past `NEPAL_MAX_MISSING` of the `NEPAL_BODIES` bodies refuses the fetch, the empty ones' ids kept too. The election is over and its files final, so the candidates are pinned as the bodies are: a fetch reading every file must read exactly `NEPAL_CANDIDATES`, so a site that trimmed its files (to the winners, or to one post) is refused however well formed what is left; with files missing or empty the pin cannot be exact, and a shortfall of up to `NEPAL_MAX_SHORT` of it is accepted, so a few candidates trimmed elsewhere are not seen then. Every listed file answered on 2026-10-24: the 18 a survey on 2026-10-23 saw refused, among them ids 5551 to 5554, answered when asked again. A file is JSON, so a body cut short does not parse, and is retried as one cut in transit rather than read as a body with fewer candidates. The fetch refuses a lookup listing other than `NEPAL_BODIES` bodies (the election is over, so its files are final), a file that is not a list of candidates, too many candidates with no romanized or no Devanagari name (`NEPAL_MAX_NAMELESS`) or with a name in the wrong script (`NEPAL_MAX_WRONG_SCRIPT`: two columns swapped, or a romanization dropped), and too few distinct names in either column (`NEPAL_MIN_DISTINCT`). A record is a distinct pair, as `tse-donors`' are, not a candidate row: a body lists a candidate once per post, and a common name recurs across bodies. The `pairs` view refuses it, its records carrying no reference name: that view compares the letters of two spellings, and a romanization shares none with its Devanagari, so the Devanagari is kept under a key of its own. `meta.json` records the keys the corpus was built from (`corpus_keys`), and a corpus written under other keys is refused on load. A fetch took 28 minutes on 2026-10-24 at `NEPAL_DELAY` (0.3 s) a request.
-`local_agreement.py` does the SCORE job for a local corpus. It parses each filed display name and compares title, given, middle and family with the filer's boxes, folding case, periods and spacing. A suffix the parser peels is compared inside the surname box: the display name joins the surname last, so a trailing suffix was typed into it whenever it is filled. A title box the parser reads otherwise, holding a word the vocabulary lists as neither a title nor a connective (a job typed as a title), is set aside as filer error rather than scored, and the report lists the commonest such titles as leads for the vocabulary. The report gives agreement overall and by nationality, misses per field, and the commonest disagreement shapes, as counts, word counts and the commonest set-aside titles; `--list N` prints moved records, names and all, for reading locally. Its record, `agreement.jsonl` beside the corpus, holds every record that did not wholly agree, so a re-run reports what moved. As with the local snapshot, record it from the base checkout (`--record --tree `) and run it from the branch; it refuses a record from another fetch, or one written by another version of the scorer, whose own change would otherwise read as the parser's moves. No CI run sees it.
+`local_agreement.py` does the SCORE job for a local corpus. It parses each filed display name and compares title, given, middle and family with the filer's boxes, folding case, periods and spacing. A suffix the parser peels is compared inside the surname box: the display name joins the surname last, so a trailing suffix was typed into it whenever it is filled. A title box the parser reads otherwise, holding a word the vocabulary lists as neither a title nor a connective (a job typed as a title), is set aside as filer error rather than scored, and the report lists the commonest such titles as leads for the vocabulary. The report gives agreement overall and by nationality, misses per field, and the commonest disagreement shapes, as counts, word counts and the commonest set-aside titles; `--list N` prints moved records, names and all, for reading locally. Its record, `agreement.jsonl` beside the corpus, holds every record that did not wholly agree, so a re-run reports what moved. As with the local snapshot, check it from the branch against a base you name (`--base `, whose record is kept under `agreements/` beside the corpus, keyed by the fetch, the base's `nameparser/` files, the interpreter's minor version and the scorer, the ten most recent kept), or, alone, record the shared one from the base checkout (`--record --tree `) and run it from the branch; it refuses a record from another fetch, or one written by another version of the scorer, whose own change would otherwise read as the parser's moves. No CI run sees it.
`pairs` reads a paired source (`tse-donors`, whose records carry the registry's name beside the filed one) as two spellings of one person, and sorts each pair by how the filed name departs from the reference: the first of its shapes that fits, from case, accents (case unchanged) and case and accents together, through words left out, shortened or misspelled, to few words in common. A word left out or added counts once per pair, and is listed only when ten pairs share it. `--agreement` also parses both spellings of every pair with a reference name that differs, and counts the pairs whose two readings end the family name in the same word (two readings with no family name share none). It lists the accents-only pairs read with two surnames by the two spellings' last words, the same ten-pair floor applied.
diff --git a/tools/differential/local_agreement.py b/tools/differential/local_agreement.py
index db37ae74..7ec88549 100644
--- a/tools/differential/local_agreement.py
+++ b/tools/differential/local_agreement.py
@@ -9,6 +9,16 @@
and its record lives in the cache beside the corpus, where a re-run
reports what moved since it was recorded:
+ uv run python tools/differential/local_agreement.py psc-individuals --base ../master-checkout
+
+--base PATH scores the base checkout from a record kept under
+agreements/ beside the corpus, keyed like snapshot.py's baselines by
+the fetch, a hash of the base's nameparser/ files, the interpreter's
+minor version and the scorer's fingerprint, and scored in a child
+process where none is kept; it never reads or writes the shared record,
+which whoever last recorded decides. Without --base a check compares
+against that shared record:
+
uv run python tools/differential/local_agreement.py psc-individuals --record --tree ../master-checkout
uv run python tools/differential/local_agreement.py psc-individuals
@@ -49,6 +59,9 @@
import argparse
import json
+import os
+import shutil
+import subprocess
import sys
import types
from collections import Counter
@@ -236,7 +249,8 @@ def record_path(source: str) -> Path:
def write_record(path: Path, local: local_corpora.Local, entries: dict[str, dict[str, Any]],
tree: str) -> None:
header = {"raw_sha256": local.meta["sha256"], "scorer": scorer_fingerprint(),
- "data_date": local.meta["data_date"], "tree": tree}
+ "data_date": local.meta["data_date"], "tree": tree,
+ "entries": len(entries)}
lines = [corpus_build.jsonl_line(header)]
lines += [corpus_build.jsonl_line({"key": k, **v}) for k, v in sorted(entries.items())]
corpus_build.write_atomic(path, ("\n".join(lines) + "\n").encode())
@@ -268,6 +282,11 @@ def read_record(path: Path, local: local_corpora.Local) -> dict[str, dict[str, A
f"{path} was recorded from another fetch of {local.source} (data as "
f"of {recorded.get('data_date', 'unknown')}; the corpus now is "
f"{local.meta['data_date']}). {again}")
+ # A record written before the header counted its entries passes; a
+ # count that disagrees is a write cut short on a line boundary.
+ if (count := recorded.get("entries", len(entries))) != len(entries):
+ raise SystemExit(f"{path} holds {len(entries):,} entries where its header "
+ f"counts {count!r}. {again}")
if recorded.get("scorer") != scorer_fingerprint():
raise SystemExit(f"{path} was recorded by another version of this scorer, "
f"so its moves would be the scorer's. {again}")
@@ -296,8 +315,75 @@ class by its methods), of corpus_build.jsonl_line, which writes an
[repr(sorted((k, repr(v)) for k, v in FIELDED.items()))])
+#: Where --base's kept records live, beside the shared one.
+KEPT = "agreements"
+
+
+def kept_path(source: str, local: local_corpora.Local, digest: str) -> Path:
+ """Where a record of `local`'s fetch scored by a package of `digest`
+ is kept: keyed by everything a record depends on besides the parser
+ (the fetch, the interpreter's minor version, the scorer), so a key
+ that matches is a record of this base."""
+ py = f"py{sys.version_info[0]}{sys.version_info[1]}"
+ return (record_path(source).parent / KEPT
+ / f"{local.meta['sha256'][:12]}-{digest[:16]}-{py}-{scorer_fingerprint()[:12]}.jsonl")
+
+
+def _score_base(root: Path, source: str, out: Path) -> None:
+ """`root` scored into `out` in a child process (one process imports
+ one nameparser), which refuses a package other than `root`'s."""
+ import snapshot
+ command = [sys.executable, str(HERE / "local_agreement.py"), source,
+ "--record", "--tree", str(root), "--out", str(out)]
+ done = subprocess.run(command, env=snapshot.child_env(), capture_output=True,
+ text=True)
+ if done.returncode:
+ raise SystemExit(f"scoring the base {root} failed:\n{done.stdout}{done.stderr}")
+
+
+def base_entries(root: Path, local: local_corpora.Local) -> dict[str, dict[str, Any]]:
+ """The base checkout's record, kept for its package's digest or
+ scored into one now, and a line saying which base was read. Never
+ the shared record: a sibling session's would pass for this base."""
+ import snapshot
+ digest = snapshot._source_digest(root)
+ path = kept_path(local.source, local, digest)
+ how = f"reused the kept record {path.name}"
+ if path.exists():
+ try:
+ entries = read_record(path, local)
+ except SystemExit as exc:
+ print(f"deleted the kept record {path.name}: {exc}; scoring the base\n")
+ path.unlink(missing_ok=True)
+ else:
+ try:
+ os.utime(path)
+ except FileNotFoundError: # another run pruned it meanwhile
+ pass
+ print(f"base: {root}, nameparser/ {digest[:16]}; {how}\n")
+ return entries
+ # Scored into a folder of this run's own and renamed into place only
+ # once the digest, taken again, says the base did not change under
+ # the child: a record published under the key it was scored for.
+ at = snapshot.scratch(path.parent)
+ try:
+ _score_base(root, local.source, at / path.name)
+ entries = read_record(at / path.name, local)
+ if snapshot._source_digest(root) != digest:
+ raise SystemExit(f"{root / 'nameparser'} changed while it was scored, "
+ f"so its record may be half old and half new; run again")
+ os.replace(at / path.name, path)
+ finally:
+ shutil.rmtree(at, ignore_errors=True)
+ snapshot._prune(path.parent, "*.jsonl")
+ print(f"base: {root}, nameparser/ {digest[:16]}; scored it now\n")
+ return entries
+
+
def _record_command(source: str) -> str:
- return (f"local_agreement.py {source} --record --tree ")
+ return (f"local_agreement.py {source} --record --tree ; or, "
+ f"where another session may be measuring, check against a base you "
+ f"name instead: local_agreement.py {source} --base ")
def moves(old: dict[str, dict[str, Any]], new: dict[str, dict[str, Any]],
@@ -339,14 +425,27 @@ def main(argv: list[str] | None = None) -> int:
ap.add_argument("--record", action="store_true",
help="rewrite the record kept beside the corpus")
ap.add_argument("--tree", metavar="PATH", help="read with another checkout")
+ ap.add_argument("--base", metavar="PATH",
+ help="compare against this checkout's score, kept by its "
+ "package's digest, instead of the shared record")
+ ap.add_argument("--out", type=Path, metavar="PATH",
+ help="where --record writes (default: the shared record)")
ap.add_argument("--top", type=int, default=15, metavar="N")
ap.add_argument("--list", type=int, default=0, metavar="N",
help="print up to N moved records of each kind, names and all")
args = ap.parse_args(argv)
+ if args.base and (args.record or args.out):
+ ap.error("--base compares and records nothing but its kept record; "
+ "drop --record and --out")
+ if args.out and not args.record:
+ ap.error("--out names where --record writes")
local = local_corpora.require(args.source)
- if not args.record:
- old = read_record(record_path(args.source), local)
root = Path(args.tree).resolve() if args.tree else HERE.parents[1]
+ if args.base:
+ from snapshot import base_root
+ old = base_entries(base_root(args.base, root), local)
+ elif not args.record:
+ old = read_record(record_path(args.source), local)
if args.tree:
sys.path.insert(0, str(root))
import nameparser
@@ -360,13 +459,18 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit(str(exc)) from None
print("\n".join(report(result, fielded, args.top)))
if args.record:
- write_record(record_path(args.source), local, result.entries, str(at.parent))
+ out = args.out or record_path(args.source)
+ write_record(out, local, result.entries, str(at.parent))
print(f"\nrecorded {len(result.entries):,} records that did not wholly "
- f"agree to {record_path(args.source)}")
+ f"agree to {out}")
return 0
found = moves(old, result.entries)
print()
- print("\n".join(summarize(found, old, result.entries)) or "matches the record")
+ if args.base:
+ from snapshot import head_line
+ print(head_line(root, Path(args.base).resolve()))
+ print("\n".join(summarize(found, old, result.entries))
+ or ("matches the base" if args.base else "matches the record"))
for kind, keys in found.items():
for k in keys[:args.list]:
print(f" {kind}: {k} recorded {old.get(k)} now {result.entries.get(k)}")
diff --git a/tools/differential/snapshot.py b/tools/differential/snapshot.py
index 649a1193..72be5390 100644
--- a/tools/differential/snapshot.py
+++ b/tools/differential/snapshot.py
@@ -25,6 +25,16 @@
committed ones, against a snapshot kept beside it in the cache, so a
change can be read against real names that are never committed:
+ uv run python tools/differential/snapshot.py --local ctgov-officials --base ../master-checkout
+
+--base PATH reads the base checkout's readings from a kept baseline
+(below), recording one in a child process where none is kept, and
+compares this tree against them; it never reads or writes the shared
+snapshot beside the corpus, so a session measuring while another
+records cannot be handed that session's readings as its base. Without
+--base a check compares against that shared snapshot, which whoever
+last recorded decides:
+
uv run python tools/differential/snapshot.py --local ctgov-officials --record --tree ../master-checkout
uv run python tools/differential/snapshot.py --local ctgov-officials
@@ -59,8 +69,10 @@
import json
import multiprocessing
import os
+import shutil
import subprocess
import sys
+import tempfile
import time
import zlib
from collections import defaultdict
@@ -407,9 +419,13 @@ def sampled(entries: list[Entry], every: int) -> list[Entry]:
#: How many recorded baselines a local corpus keeps, whole or sampled
-#: alike, the least recently recorded or reused dropped first: an FEC
-#: snapshot was 249 MB plain and 41 MB gzipped on 2026-10-23.
-BASELINES_KEPT = 4
+#: alike, the least recently recorded or reused dropped first. Sized for
+#: four sessions measuring at once, each keeping a base and a head with
+#: room for one more: at 4 (before --base) parallel sessions evicted
+#: each other's bases (2026-10-25). The largest is FEC's, 249 MB plain
+#: and 41.7 MB gzipped (2026-10-25), so ten cost about 420 MB for it and
+#: about 1.2 GB across the eight local corpora (122 MB a reading).
+BASELINES_KEPT = 10
#: A partial baseline older than this is a crashed write's, not a
#: running one's, and is deleted when a baseline is kept.
_PARTIAL_STALE_SECONDS = 86400
@@ -458,11 +474,11 @@ def _reused(baseline: Path, entries: int) -> str | None:
damaged one -- unreadable, or holding another number of rows than
the corpus has entries, as a write cut short would -- is deleted
with a note, and the names are read instead."""
- if not baseline.exists():
- return None
try:
with gzip.open(baseline, "rt", encoding="utf-8") as f:
text = f.read()
+ except FileNotFoundError: # none kept, or another run pruned it
+ return None
except (OSError, EOFError, zlib.error, UnicodeDecodeError) as e:
why = f"unreadable ({e})"
else:
@@ -470,7 +486,7 @@ def _reused(baseline: Path, entries: int) -> str | None:
return text
why = f"{rows} rows for {entries} entries"
print(f"deleted the kept baseline {baseline.name}: {why}; reading the names\n")
- baseline.unlink()
+ baseline.unlink(missing_ok=True) # another run may have found it too
return None
@@ -504,18 +520,31 @@ def _mtime(path: Path) -> float | None:
return None
-def _prune(folder: Path) -> None:
- """Drop all but the BASELINES_KEPT most recently used baselines, and
- partial writes old enough to be a crashed run's. A file another run
- removed first is skipped, not reported."""
+def _prune(folder: Path, pattern: str = "*.jsonl.gz") -> None:
+ """Drop all but the BASELINES_KEPT most recently used `pattern`
+ files, and partial writes old enough to be a crashed run's, a base's
+ scratch folder (scratch()) among them. A file another run removed
+ first is skipped, not reported."""
stale = time.time() - _PARTIAL_STALE_SECONDS
- kept = sorted((m, p) for p in folder.glob("*.jsonl.gz")
+ kept = sorted((m, p) for p in folder.glob(pattern)
if (m := _mtime(p)) is not None)
old = [p for _, p in kept[:-BASELINES_KEPT]]
- old += [p for p in folder.glob("*.partial")
+ old += [p for glob in ("*.partial", "*.part") for p in folder.glob(glob)
if (m := _mtime(p)) is not None and m < stale]
for p in old:
- p.unlink(missing_ok=True)
+ if p.is_dir():
+ shutil.rmtree(p, ignore_errors=True)
+ else:
+ p.unlink(missing_ok=True)
+
+
+def scratch(folder: Path) -> Path:
+ """A folder of this run's own for a child's recording, inside the
+ kept records' `folder`: unique, so two sessions recording the same
+ base at once never share a file, and named as a partial write, so
+ _prune sweeps one a killed run left behind."""
+ folder.mkdir(parents=True, exist_ok=True)
+ return Path(tempfile.mkdtemp(prefix=".base-", suffix=".partial", dir=folder))
def _fetch_record(snapshot: Path) -> Path:
@@ -542,7 +571,9 @@ def _check_fetch(snapshot: Path, fetch: dict[str, Any], source: str) -> None:
f"is {fetch['data_date']}), so it would report the names the "
f"source gained and lost as moves. Record it again from the "
f"base checkout: snapshot.py --local {source} --record "
- f"--tree ")
+ f"--tree ; or, where another session may be "
+ f"measuring, check against a base you name instead: "
+ f"snapshot.py --local {source} --base ")
if recorded.get("sample") != fetch.get("sample"):
raise SystemExit(
f"{snapshot} was recorded from "
@@ -555,12 +586,97 @@ def _describe_sample(sample: object) -> str:
return "the whole corpus" if sample is None else f"a 1-in-{sample} sample"
+def base_root(path: str, head: Path) -> Path:
+ """--base's checkout, resolved: refused where it holds no nameparser
+ package, whose digest would key an empty tree, or where it is the
+ tree being read, which would compare a tree with itself and report
+ no moves whatever the change."""
+ root = Path(path).resolve()
+ if not (root / "nameparser" / "__init__.py").is_file():
+ raise SystemExit(f"--base {path}: no nameparser package at "
+ f"{root / 'nameparser'}; name a checkout")
+ if root == head:
+ raise SystemExit(f"--base {path} is the tree being read ({head}): a "
+ f"tree compared with itself reports no moves. Name "
+ f"the base checkout, or pass --tree for the other side")
+ return root
+
+
+def head_line(root: Path, base: Path) -> str:
+ """Which tree was read against the base, by its package's digest,
+ taken after the read so that it covers what the import saw. Two
+ equal digests are said outright: a check that cannot move is a
+ clean run's look-alike."""
+ head = _source_digest(root)
+ if head == _source_digest(base):
+ return (f"head: {root} holds a nameparser/ byte-identical to the "
+ f"base's ({head[:16]}), so no reading can move\n")
+ return f"head: {root}, nameparser/ {head[:16]}\n"
+
+
+def child_env() -> dict[str, str]:
+ """A child's environment with the import-path overrides stripped, as
+ compare.py's worker has it: a PYTHONPATH entry would outrank the
+ --tree the child is handed (check_source then aborts it)."""
+ return {k: v for k, v in os.environ.items()
+ if k not in ("PYTHONPATH", "PYTHONHOME")}
+
+
+def _record_base(root: Path, source: str, sample: int | None, jobs: int,
+ out: Path) -> None:
+ """`root`'s readings of `source` recorded to `out` in a child process
+ (one process imports one nameparser), which keeps them as a baseline
+ and refuses a package other than `root`'s, as --record --tree does."""
+ command = [sys.executable, str(HERE / "snapshot.py"), "--local", source,
+ "--record", "--tree", str(root), "--out", str(out),
+ "--jobs", str(jobs)]
+ if sample is not None:
+ command += ["--sample", str(sample)]
+ done = subprocess.run(command, env=child_env(), capture_output=True, text=True)
+ if done.returncode:
+ raise SystemExit(f"recording the base {root} failed:\n{done.stdout}{done.stderr}")
+
+
+def _base_text(root: Path, snapshot: Path, fetch: dict[str, Any], source: str,
+ sample: int | None, entries: int, jobs: int) -> str:
+ """The base checkout's readings, from the baseline kept for its
+ package's digest, or recorded into one now, and a line saying which
+ base was read. Never the shared snapshot: that is whoever recorded
+ last, and a sibling session's record would pass for this base."""
+ digest = _source_digest(root)
+ baseline = _baseline(snapshot, fetch, digest)
+ if (text := _reused(baseline, entries)) is not None:
+ try:
+ os.utime(baseline)
+ except FileNotFoundError: # another run pruned it meanwhile
+ pass
+ how = f"reused the kept baseline {baseline.name}"
+ else:
+ at = scratch(baseline.parent)
+ try:
+ _record_base(root, source, sample, jobs, at / "base.jsonl")
+ text = (at / "base.jsonl").read_text(encoding="utf-8")
+ finally:
+ shutil.rmtree(at, ignore_errors=True)
+ if _source_digest(root) != digest:
+ raise SystemExit(f"{root / 'nameparser'} changed while it was read, "
+ f"so its readings may be half old and half new; "
+ f"run again")
+ how = "recorded it now"
+ print(f"base: {root}, nameparser/ {digest[:16]}; {how}\n")
+ return text
+
+
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--record", action="store_true", help="rewrite the snapshot")
ap.add_argument("--since", metavar="REF",
help="compare against the snapshot committed at a git ref")
ap.add_argument("--tree", metavar="PATH", help="read with another checkout")
+ ap.add_argument("--base", metavar="PATH",
+ help="with --local: compare against the readings of this "
+ "checkout, kept by its package's digest, instead of "
+ "the shared snapshot")
ap.add_argument("--out", type=Path,
help="where --record writes (default: snapshot.jsonl, "
"or the local corpus's own snapshot)")
@@ -579,16 +695,23 @@ def main(argv: list[str] | None = None) -> int:
ap.error("--sample reads a local corpus; the committed snapshot is whole")
if args.sample is not None and args.sample < 2:
ap.error("--sample N keeps 1 name in N, so N must be 2 or more")
+ if args.base and not args.local:
+ ap.error("--base reads a local corpus; for the committed corpora, "
+ "--since compares against a ref")
+ if args.base and (args.record or args.out):
+ ap.error("--base compares and records nothing but its kept baseline; "
+ "drop --record and --out")
snapshot, corpora, fetch = SNAPSHOT, None, None
if args.local:
snapshot, corpora, fetch = _local(args.local, args.sample)
out = args.out or snapshot
- if not (args.record or args.since or snapshot.exists()):
+ root = Path(args.tree).resolve() if args.tree else REPO_ROOT
+ base = base_root(args.base, root) if args.base else None
+ if not (args.record or args.since or base or snapshot.exists()):
raise SystemExit(f"no snapshot at {snapshot}: record one first, "
f"with --record (and --tree for another checkout)")
- if fetch is not None and not args.record:
+ if fetch is not None and not (args.record or base):
_check_fetch(snapshot, fetch, args.local)
- root = Path(args.tree).resolve() if args.tree else REPO_ROOT
entries = corpus_entries(corpora)
if args.sample is not None:
whole, entries = len(entries), sampled(entries, args.sample)
@@ -610,9 +733,16 @@ def main(argv: list[str] | None = None) -> int:
return 0
if args.tree:
sys.path.insert(0, str(root))
+ based = None
+ if base is not None:
+ assert fetch is not None
+ based = _base_text(base, snapshot, fetch, args.local, args.sample,
+ len(entries), args.jobs)
rows = read(entries, facade=not args.local, jobs=args.jobs)
import nameparser
check_source(nameparser.__file__, root)
+ if base is not None:
+ print(head_line(root, base))
if args.record:
text = dump(rows)
out.write_text(text, encoding="utf-8")
@@ -638,6 +768,8 @@ def main(argv: list[str] | None = None) -> int:
f"no {rel} at {args.since}: an unknown ref, or one from "
f"before the snapshot existed. For a release tagged "
f"before it, use `compare.py --baseline `") from None
+ elif based is not None:
+ text = based
else:
text = snapshot.read_text(encoding="utf-8")
old, new = load(text), {key(r): r for r in rows}
@@ -651,14 +783,19 @@ def main(argv: list[str] | None = None) -> int:
f"against records no case view (it predates #826); "
f"compare.py --baseline compares them\n")
moves = grouped(old, new)
- print("\n".join(moves) or "matches the snapshot")
+ print("\n".join(moves) or ("matches the base" if base else "matches the snapshot"))
if not moves:
return 0
moved = {k for k in set(old) | set(new) if old.get(k) != new.get(k)}
+ if base is not None:
+ print(f"\n{len(moved)} entries differ from the base {base}")
+ return 1
if args.local:
print(f"\n{len(moved)} entries differ from the snapshot recorded "
- f"from this fetch. To make another tree the baseline: "
- f"snapshot.py --local {args.local} --record --tree ")
+ f"from this fetch, which whoever recorded last chose. To make "
+ f"another tree the baseline: snapshot.py --local {args.local} "
+ f"--record --tree ; to check against a base you "
+ f"name instead: snapshot.py --local {args.local} --base ")
return 1
cldr = only_in(CLDR_CORPUS, {name for name, _ in moved})
print(f"\n{len(moved)} entries differ, {len(cldr)} of their names "