diff --git a/AGENTS.md b/AGENTS.md index b17435c6..4e049002 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -275,7 +275,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`; 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. +**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`; 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 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. ## Gotchas diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index 3cc47971..7ff44d67 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -410,11 +410,11 @@ print(len(MAIDEN_MARKERS), 'members ->', len(cls), 'structural classes')" **Problem shape.** A new source of names turns up (a registry, a dataset, a dump) and the question is what to build for it: a committed corpus, a column in a tool, a script that writes vocabulary. -**Contract statement.** A name source does one or more of five jobs — WATCH, SCORE, COUNT, DERIVE and SURVEY — and the job decides where its data lives. Only WATCH in CI and SCORE need the names in the repository, so a source whose names cannot be committed still does COUNT and SURVEY, WATCH outside CI, and DERIVE where its terms let a derived list ship. +**Contract statement.** A name source does one or more of five jobs — WATCH, SCORE, COUNT, DERIVE and SURVEY — and the job decides where its data lives. Only WATCH and SCORE in CI need the names in the repository, so a source whose names cannot be committed still does COUNT and SURVEY, WATCH and SCORE outside CI, and DERIVE where its terms let a derived list ship. -**How it works.** WATCH asks whether a change moved a name: a committed corpus, `snapshot.jsonl` and a test that fails, or a local corpus read with `snapshot.py --local`, which no CI run sees. SCORE asks whether the parser agrees with someone who knows the fields: a committed fields file and agreement record, read by `cldr_agreement.py` from the repository alone. COUNT asks how one word is used: a cached source behind a row of `tools/name_usage.py`, with only the counts cited. DERIVE asks what a shipped list should hold: a cached source and a script writing the vocabulary (`tools/zh_surnames.py`). SURVEY asks how people write whole names, and how OFTEN they write each shape: a cached local corpus (`tools/differential/local_corpora.py`). +**How it works.** WATCH asks whether a change moved a name: a committed corpus, `snapshot.jsonl` and a test that fails, or a local corpus read with `snapshot.py --local`, which no CI run sees. SCORE asks whether the parser agrees with someone who knows the fields: a committed fields file and agreement record, read by `cldr_agreement.py` from the repository alone, or a local corpus whose records carry the fields their filers typed, with its record kept in the cache beside it (`local_agreement.py`). The local score tells a change's author what moved among real filed names, by field, and gives agreement by group and how often each disagreement shape occurs. It cannot guard anything: no CI run and no reviewer sees it, and no test fails when it moves (the tool itself exits 1). Its truth is the filer's, so a box filled wrongly (a job typed as a title) reads as a miss unless the scorer sets it aside, and the scorer has to say what it sets aside. COUNT asks how one word is used: a cached source behind a row of `tools/name_usage.py`, with only the counts cited. DERIVE asks what a shipped list should hold: a cached source and a script writing the vocabulary (`tools/zh_surnames.py`). SURVEY asks how people write whole names, and how OFTEN they write each shape: a cached local corpus (`tools/differential/local_corpora.py`). -A second axis cuts across the jobs: whether the names are real or invented, labelled with fields or bare text, chosen or unselected. Every committed corpus is CHOSEN: invented to fill fields (CLDR, the conventions table, the rules examples), cataloged (GND), filtered to names whose every word aligns with a stated part (both Wikidata corpora, a filter that drops credentials and typos), or picked by someone testing or reporting a case (v1's test names in `corpus.jsonl`, the case-table rows in `corpus_cjk.jsonl` and `corpus_shapes.jsonl`, and the strings reporters typed into `corpus_issues.jsonl`). The last group does hold hand-typed shapes — a job title in the name (`Bob Jones, author`), a credential before the comma (`Washington Jr. MD, Franklin`) — but one instance of each, as chosen. What SURVEY adds is the unselected population and its frequencies: ClinicalTrials.gov's study officials (local corpus `ctgov-officials`, measured 2026-10-21) are typed into a free-text field by sponsors' staff, so they show how often each shape occurs, and hold some no committed corpus does, such as `MBBS,FCPS` with no space. The FEC's individual contributors (local corpus `fec-individuals`, measured 2026-10-22) are unselected too, but mostly a filer's software writing a form's fields in a fixed order, so nearly all of its 1.89 million names share one shape (capitals, family, comma, given) and the hand-typed part is what goes inside the fields (a credential run or a suffix after a second comma): the population for that shape's variants, and blind to case. The UK Electoral Commission's individual donors (local corpus `ukec-individuals`, measured 2026-10-22) are the opposite shape: names as a donee's return gave them, mixed case and in spoken order, `parse()` reading a title in 8,219 of 15,214 (`Mr`, `Lord`, `Sir`) and only 2 holding a comma. Brazil's individual donors to candidates (local corpus `tse-donors`, measured 2026-10-22) are the FEC's comma-less counterpart: 848,351 filed names, nearly all in capitals and in spoken order, full of particles and agnomens (`DE`, `DA`, `DOS`; `FILHO`, `NETO`, `JUNIOR`), each beside the tax registry's name for the same donor, so a typed form can be read against its reference form (205,925 of 854,531 pairs differ). One source may do several jobs, each through its own code: Wikidata does COUNT live and WATCH and SCORE through its builders. A live source has no fixed file to pin, so its cached copy is the pin, carrying the source's own data date and a sha256 checked on every load (a truncated file otherwise reads as a run of false zeros, #775). +A second axis cuts across the jobs: whether the names are real or invented, labelled with fields or bare text, chosen or unselected. Every committed corpus is CHOSEN: invented to fill fields (CLDR, the conventions table, the rules examples), cataloged (GND), filtered to names whose every word aligns with a stated part (both Wikidata corpora, a filter that drops credentials and typos), or picked by someone testing or reporting a case (v1's test names in `corpus.jsonl`, the case-table rows in `corpus_cjk.jsonl` and `corpus_shapes.jsonl`, and the strings reporters typed into `corpus_issues.jsonl`). The last group does hold hand-typed shapes — a job title in the name (`Bob Jones, author`), a credential before the comma (`Washington Jr. MD, Franklin`) — but one instance of each, as chosen. What SURVEY adds is the unselected population and its frequencies: ClinicalTrials.gov's study officials (local corpus `ctgov-officials`, measured 2026-10-21) are typed into a free-text field by sponsors' staff, so they show how often each shape occurs, and hold some no committed corpus does, such as `MBBS,FCPS` with no space. The FEC's individual contributors (local corpus `fec-individuals`, measured 2026-10-22) are unselected too, but mostly a filer's software writing a form's fields in a fixed order, so nearly all of its 1.89 million names share one shape (capitals, family, comma, given) and the hand-typed part is what goes inside the fields (a credential run or a suffix after a second comma): the population for that shape's variants, and blind to case. The UK Electoral Commission's individual donors (local corpus `ukec-individuals`, measured 2026-10-22) are the opposite shape: names as a donee's return gave them, mixed case and in spoken order, `parse()` reading a title in 8,219 of 15,214 (`Mr`, `Lord`, `Sir`) and only 2 holding a comma. Brazil's individual donors to candidates (local corpus `tse-donors`, measured 2026-10-22) are the FEC's comma-less counterpart: 848,351 filed names, nearly all in capitals and in spoken order, full of particles and agnomens (`DE`, `DA`, `DOS`; `FILHO`, `NETO`, `JUNIOR`), each beside the tax registry's name for the same donor, so a typed form can be read against its reference form (205,925 of 854,531 pairs differ). Companies House's people with significant control (local corpus `psc-individuals`, measured 2026-10-22) are unselected and labelled at once: each filer types the name into four boxes (title, forename, middle name, surname) and states a nationality, so the corpus is the one real population a local SCORE reads by nationality. It is filed through a form's boxes, so it holds almost no comma (838 of 1,040,708 sampled records) or credential. One source may do several jobs, each through its own code: Wikidata does COUNT live and WATCH and SCORE through its builders. A live source has no fixed file to pin, so its cached copy is the pin, carrying the source's own data date and a sha256 checked on every load (a truncated file otherwise reads as a run of false zeros, #775). **Reach for it when.** A source is proposed, before choosing between a builder and a tool column; and when a corpus would hold real people's names nobody vetted for publication, which is the case for a local corpus rather than a committed one. diff --git a/tests/v2/test_local_agreement.py b/tests/v2/test_local_agreement.py new file mode 100644 index 00000000..60205c4d --- /dev/null +++ b/tests/v2/test_local_agreement.py @@ -0,0 +1,280 @@ +"""Offline checks on tools/differential/local_agreement.py. + +The score compares a parse of each filed display name with the boxes the +filer typed it into, so what these pin is the comparison's three +decisions (the fold, the suffix counted in the surname box, the title +box set aside as filer error), each beside the reading with the decision +off, and the record's refusals: no record, a record that is not one, a record of +another fetch or another scorer, and a corpus damaged under the stream. The names are invented. +""" +import json +import sys +from pathlib import Path + +import pytest + +from nameparser import Parser + +from ._differential_fixtures import load_tool + +agreement = load_tool("local_agreement") +corpora = agreement.local_corpora +_PSC = corpora.PSC +_PSC_FIELDS = agreement.FIELDED[_PSC] +_LISTED = agreement._title_words(Parser().lexicon) + + +@pytest.fixture(autouse=True) +def _own_cache(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> Path: + """Every test's cache is its own, as in test_local_corpora.py.""" + path = tmp_path_factory.mktemp("cache") + monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(path)) + return path + + +def _filed(title: str = "", forename: str = "", middle_name: str = "", surname: str = "", + nationality: str = "British") -> dict[str, str]: + boxes = {"title": title, "forename": forename, "middle_name": middle_name, + "surname": surname} + return {"name": " ".join(v for v in boxes.values() if v), "nationality": nationality, + **{k: v for k, v in boxes.items() if v}} + + +_AGREES = _filed("Mrs", "Jo", surname="Ng") +_DOUBLE_SURNAME = _filed("", "Ana", surname="García López", nationality="Spanish") +_SUFFIX = _filed("Mr", "Bob", surname="Roe Jr") +_JOB_TITLE = _filed("Managing Partner", "Cy", surname="Doe") +_SHAPE_TITLE = _filed("Insp.", "Di", surname="Fox") + + +def _read(record: dict[str, str]) -> tuple[dict[str, str], bool, bool]: + """The record's reading: the fields that disagree, whether it was + set aside, whether the family agreed by the suffix.""" + reading = agreement.read(record, _PSC_FIELDS, Parser(), _LISTED) + return reading.got, reading.set_aside, reading.by_suffix + + +def test_a_box_agrees_whatever_its_case_periods_and_spacing( + monkeypatch: pytest.MonkeyPatch) -> None: + assert agreement.fold(" Mr. ANN ") == "mr ann" + upper = _filed("MR", "ANN", surname="LEE") + upper["forename"] = "Ann." + assert _read(upper)[0] == {} + # The control: compared as written, the box disagrees. + monkeypatch.setattr(agreement, "fold", lambda text: text) + assert _read(upper)[0] == {"given": "ANN"} + + +def test_a_two_word_surname_read_with_a_middle_name_is_two_misses() -> None: + got = _read(_DOUBLE_SURNAME)[0] + assert got == {"middle": "García", "family": "López"} + assert agreement.shape(_DOUBLE_SURNAME, _PSC_FIELDS, got) == ( + "middle 0->1, family 2->1") + + +def test_a_peeled_suffix_is_compared_inside_the_surname_box() -> None: + assert _read(_SUFFIX) == ({}, False, True) + assert _read(_AGREES) == ({}, False, False) + # The control: the parser's family alone leaves the box short. + parsed = Parser().parse(_SUFFIX["name"]) + assert (parsed.family, parsed.suffix) == ("Roe", "Jr") + + +def test_a_title_box_holding_an_unlisted_word_the_parse_reads_otherwise_is_set_aside( + monkeypatch: pytest.MonkeyPatch) -> None: + got, set_aside, _ = _read(_JOB_TITLE) + assert set_aside and "title" in got + # An unlisted title the parse reads as the title, by its period, is + # scored, and agrees. + assert agreement.unlisted("Insp.", _LISTED) + assert not agreement.unlisted("Mr. and MRS", _LISTED) # a connective is listed + assert _read(_SHAPE_TITLE) == ({}, False, False) + # The control: with every word listed, the job is three misses. + monkeypatch.setattr(agreement, "unlisted", lambda title, listed: False) + assert _read(_JOB_TITLE) == ( + {"title": "", "given": "Managing", "middle": "Partner Cy"}, False, False) + + +def _corpus(monkeypatch: pytest.MonkeyPatch, *records: dict[str, str]) -> None: + """A PSC corpus of `records` fetched into the cache, no network behind it.""" + monkeypatch.setitem(corpora.SOURCES, _PSC, corpora.Source( + corpora.SOURCES[_PSC].credit, "filed names", + lambda: corpora.Fetched(list(records), "2026-10-22", {}))) + corpora.fetch(_PSC) + + +_ALL = (_AGREES, _DOUBLE_SURNAME, _SUFFIX, _JOB_TITLE, _SHAPE_TITLE) + + +def test_the_report_counts_by_group_and_field_and_prints_no_name( + monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(agreement, "GROUP_FLOOR", 1) + monkeypatch.setattr(agreement, "TITLE_FLOOR", 1) + result = agreement.score(list(_ALL), _PSC_FIELDS, Parser()) + assert result.scored == {"British": 3, "Spanish": 1} + assert result.agreed == {"British": 3} + assert result.misses == {"Spanish": {"middle": 1, "family": 1}} + assert result.set_aside == {"Managing Partner": 1} + assert result.by_suffix == 1 + assert set(result.entries) == {agreement.key(_DOUBLE_SURNAME), agreement.key(_JOB_TITLE)} + lines = agreement.report(result, _PSC_FIELDS, 5) + assert "every field agrees: 3 of 4 (75.0%); 1 of them with the parser's suffix" \ + in "\n".join(lines) + assert " British 3 100.0% 0 0 0 0" \ + in lines + names = {w for r in _ALL for k, v in r.items() if k in ("forename", "surname") + for w in v.split()} + printed = " ".join(lines).replace("Managing Partner", "") + assert not {w for w in names if w in printed.split()} + + +def test_a_stream_of_damaged_records_is_refused_once_read( + monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: + _corpus(monkeypatch, *_ALL) + loaded = corpora.load(_PSC) + assert len(list(agreement.records(loaded))) == len(_ALL) # the control + raw = _own_cache / "local" / _PSC / "raw.jsonl" + raw.write_text(raw.read_text().replace("Jo", "Al")) + with pytest.raises(corpora.CorpusDamaged, match="does not match the sha256"): + list(agreement.records(loaded)) + + +def test_a_rerun_reports_what_moved_since_the_record( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + _corpus(monkeypatch, *_ALL) + assert agreement.main([_PSC, "--record"]) == 0 + assert agreement.main([_PSC]) == 0 # the control: nothing moved + assert "matches the record" in capsys.readouterr().out + # A parser that reads every name with its words reversed moves them all. + real = Parser.parse + monkeypatch.setattr(Parser, "parse", + lambda self, text: real(self, " ".join(reversed(text.split())))) + assert agreement.main([_PSC]) == 1 + out = capsys.readouterr().out + assert "regression: 3 records (" in out and "progress" not in out + assert "changed: 2 records" in out + + +def test_a_record_of_another_fetch_is_refused( + monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: + _corpus(monkeypatch, *_ALL) + assert agreement.main([_PSC, "--record"]) == 0 + _corpus(monkeypatch, *_ALL[:2]) + with pytest.raises(SystemExit, match="recorded from another fetch"): + 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"} + + +def test_a_run_with_no_record_names_the_command_that_writes_one( + monkeypatch: pytest.MonkeyPatch) -> None: + _corpus(monkeypatch, *_ALL) + with pytest.raises(SystemExit, match="--record --tree"): + agreement.main([_PSC]) + + +def test_a_set_aside_title_shared_by_too_few_records_is_not_printed( + monkeypatch: pytest.MonkeyPatch) -> None: + typed = _filed("Jo Bloggs", "Cy", surname="Doe") # a name in the title box + result = agreement.score([typed] * 3 + [_JOB_TITLE], _PSC_FIELDS, Parser()) + assert result.set_aside == {"Jo Bloggs": 3, "Managing Partner": 1} + assert "Bloggs" not in "\n".join(agreement.report(result, _PSC_FIELDS, 5)) + # The control: under a floor of 3 the box shared by 3 records is + # printed, and the one typed once is not. + monkeypatch.setattr(agreement, "TITLE_FLOOR", 3) + printed = "\n".join(agreement.report(result, _PSC_FIELDS, 5)) + assert "Jo Bloggs" in printed and "Managing" not in printed + + +def test_a_group_below_the_floor_has_no_row_and_rows_run_by_size( + monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(agreement, "GROUP_FLOOR", 2) + rows = [_AGREES, _filed("Mr", "Bo", surname="Wu", nationality=" british "), + _filed("Ms", "Di", surname="Fox", nationality="Irish"), + _filed("Ms", "Al", surname="Ng", nationality="Irish"), + _filed("Ms", "Ed", surname="Lin", nationality="Irish"), _DOUBLE_SURNAME, + _filed("Mr", "Cy", surname="Roe", nationality="")] + result = agreement.score(rows, _PSC_FIELDS, Parser()) + assert result.scored == {"Irish": 3, "British": 2, "Spanish": 1, "(none)": 1} + groups = [line.split()[0] for line in agreement.report(result, _PSC_FIELDS, 5) + if line.startswith(" ") and line.split()[0] in result.scored] + assert groups == ["Irish", "British"] + + +def test_a_line_that_is_not_a_record_is_refused_as_damage( + monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: + _corpus(monkeypatch, *_ALL) + loaded = corpora.load(_PSC) + raw = _own_cache / "local" / _PSC / "raw.jsonl" + for damage in ('{"name": ', '["a list"]', '{"name": "Al Ng", "surname": 1}'): + lines = raw.read_text().splitlines(keepends=True) + raw.write_text("".join(lines[:1] + [damage + "\n"] + lines[1:])) + # Through score(), which reads each record before the stream ends. + with pytest.raises(corpora.CorpusDamaged, match="fetch it again"): + agreement.score(agreement.records(loaded), _PSC_FIELDS, Parser()) + raw.write_text("".join(lines)) + assert len(list(agreement.records(loaded))) == len(_ALL) # the control + + +@pytest.mark.parametrize("body", ["", "{not json\n", '["a list"]\n', + '{"raw_sha256": "x"}\n{"got": {}}\n'], + ids=["empty", "header not JSON", "header not an object", + "a row without its key"]) +def test_a_record_that_is_not_one_is_refused_naming_the_record_command( + monkeypatch: pytest.MonkeyPatch, _own_cache: Path, body: str) -> None: + _corpus(monkeypatch, *_ALL) + assert agreement.main([_PSC, "--record"]) == 0 + (_own_cache / "local" / _PSC / agreement.RECORD).write_text(body) + with pytest.raises(SystemExit, match="is not a record this tool wrote.*--record"): + agreement.main([_PSC]) + + +def test_a_record_written_by_another_scorer_is_refused( + monkeypatch: pytest.MonkeyPatch) -> None: + _corpus(monkeypatch, *_ALL) + assert agreement.main([_PSC, "--record"]) == 0 + before = agreement.scorer_fingerprint() + # Each part of the code that decides a record's entry moves the + # fingerprint: how a field is compared, which records get an entry, + # and how an entry is keyed. + for name, changed in (("fold", lambda text: text.casefold()), + ("score", lambda rows, fielded, parser: None), + ("key", lambda record: str(record))): + with monkeypatch.context() as patch: + patch.setattr(agreement, name, changed) + assert agreement.scorer_fingerprint() != before, name + with monkeypatch.context() as patch: + patch.setattr(agreement.corpus_build, "jsonl_line", + lambda obj, sort_keys=False: str(obj)) + assert agreement.scorer_fingerprint() != before, "jsonl_line" + assert agreement.scorer_fingerprint() == before + monkeypatch.setattr(agreement, "fold", lambda text: text.casefold()) + with pytest.raises(SystemExit, match="another version of this scorer"): + agreement.main([_PSC]) + + +def test_progress_is_reported_where_a_disagreement_now_agrees( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + _corpus(monkeypatch, *_ALL) + real = Parser.parse + monkeypatch.setattr(Parser, "parse", lambda self, text: real( + self, "Al Ng" if text == _AGREES["name"] else text)) + assert agreement.main([_PSC, "--record"]) == 0 + monkeypatch.setattr(Parser, "parse", real) + assert agreement.main([_PSC, "--list", "1"]) == 1 + out = capsys.readouterr().out + assert "progress: 1 records (" in out and "regression" not in out + assert f" progress: {agreement.key(_AGREES)}" in out + + +def test_a_tree_that_is_not_the_imported_package_is_refused( + monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None: + _corpus(monkeypatch, *_ALL) + monkeypatch.setattr(sys, "path", list(sys.path)) # main puts the tree on it + with pytest.raises(SystemExit, match="not from the source package"): + agreement.main([_PSC, "--record", "--tree", str(tmp_path)]) + assert not (_own_cache / "local" / _PSC / agreement.RECORD).exists() + # The control: this checkout itself is the tree it imported. + root = agreement.HERE.parents[1] + assert agreement.main([_PSC, "--record", "--tree", str(root)]) == 0 diff --git a/tests/v2/test_local_corpora.py b/tests/v2/test_local_corpora.py index 4cfee875..a9651d3c 100644 --- a/tests/v2/test_local_corpora.py +++ b/tests/v2/test_local_corpora.py @@ -10,11 +10,14 @@ """ import csv import email.message +import gzip import hashlib import http.client import io import json +import os import re +import shutil import time import types import unicodedata @@ -1879,6 +1882,406 @@ def urlopen(request: urllib.request.Request, timeout: int) -> _Response: local.fetch(_UKEC) +# -- Companies House people with significant control ------------------------------ + +_PSC = "psc-individuals" +_PSC_DAY = "2026-10-22" +_PSC_PERSON = "individual-person-with-significant-control" + + +def _psc_person(name: str, nationality: str | None = "British", + kind: str = _PSC_PERSON, **elements: str) -> dict[str, Any]: + """A PSC record as the snapshot writes it, its boxes from `elements` + (or split from `name`: forename, then surname), with the fields the + corpus must not keep.""" + if not elements: + forename, _, surname = name.partition(" ") + elements = {"title": "Mr", "forename": forename, "surname": surname} + name = f"Mr {name}" + data: dict[str, Any] = { + "kind": kind, "name": name, "name_elements": elements, + "address": {"premises": "1 Invented Row", "postal_code": "ZZ1 1ZZ"}, + "date_of_birth": {"month": 4, "year": 1970}, "country_of_residence": "England", + "natures_of_control": ["ownership-of-shares-75-to-100-percent"]} + if nationality is not None: + data["nationality"] = nationality + return {"company_number": "00000001", "data": data} + + +def _psc_lines(*records: dict[str, Any] | str) -> bytes: + return b"".join((r if isinstance(r, str) else json.dumps(r)).encode() + b"\n" + for r in records) + + +#: Part 1: a person, a person filed twice (two companies), a company, a +#: super-secure PSC with no name, a person with no nationality, and a +#: person whose name and boxes are all empty. +#: Part 2: the first person again, filed by another company, and an +#: overseas entity's beneficial owner. +_PSC_PARTS = { + 1: _psc_lines(_psc_person("Ann Lee"), _psc_person("Bo Wu"), _psc_person("Bo Wu"), + {"data": {"kind": "corporate-entity-person-with-significant-control", + "name": "Invented Holdings Ltd"}}, + {"data": {"kind": "super-secure-person-with-significant-control"}}, + _psc_person("Cy Doe", nationality=None), + _psc_person("", title="", forename="", surname="")), + 2: _psc_lines(_psc_person("Ann Lee"), + _psc_person("Dr Di Fox Roe", "Irish", "individual-beneficial-owner", + title="Dr", forename="Di", middle_name="Fox", + surname="Roe")), +} + + +def _psc_part(n: int, body: bytes, of: int = 2, day: str = _PSC_DAY, + member: str | None = None) -> bytes: + """Part `n` of `of` as the download serves it: a zip of one JSON-lines file.""" + out = io.BytesIO() + with zipfile.ZipFile(out, "w") as archive: + archive.writestr(member or f"psc-snapshot-{day}_{n}of{of}.txt", body) + return out.getvalue() + + +def _psc_page(*files: str) -> bytes: + return "".join(f'{f} (74Mb)\n' for f in files).encode() + + +def _psc_files(of: int = 2, day: str = _PSC_DAY) -> list[str]: + return [f"psc-snapshot-{day}_{n}of{of}.zip" for n in range(1, of + 1)] + + +def _serve_psc(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + parts: dict[str, Answer], page: bytes | None = None) -> list[str]: + """Answer the download page with `page` (every file in `parts` + linked) and each part file with its answer: bytes to serve, or an + exception to raise. Returns the file names downloaded.""" + monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) + monkeypatch.setattr(local, "FEC_CHUNK", 64) # several reads, not one + monkeypatch.setattr(local, "PSC_SAMPLE", 1) # every record, unless a test samples + page = _psc_page(*parts) if page is None else page + asked: list[str] = [] + + def urlopen(request: urllib.request.Request, timeout: int) -> _Response | _Download: + url = request.full_url + if url == local.PSC_PAGE: + return _Response(page) + file = url.rsplit("/", 1)[1] + asked.append(file) + answer = parts.get(file) + if isinstance(answer, Exception): + raise answer + if answer is None: + pytest.fail(f"an unexpected download of {url}") + return _Download(answer, len(answer)) + monkeypatch.setattr(urllib.request, "urlopen", urlopen) + return asked + + +def _psc_snapshot(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + bodies: dict[int, bytes] = _PSC_PARTS, day: str = _PSC_DAY) -> list[str]: + of = len(bodies) + return _serve_psc(monkeypatch, tmp_path, { + file: _psc_part(n, bodies[n], of, day) + for n, file in enumerate(_psc_files(of, day), 1)}) + + +def _gone(file: str) -> urllib.error.HTTPError: + return urllib.error.HTTPError(f"{local.PSC_PAGE}/{file}", 404, "Not Found", + email.message.Message(), None) + + +def test_a_psc_fetch_keeps_each_filed_name_once_and_counts_the_rest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(local, "PSC_MAX_BAD", 1) # a fixture of 9 lines + asked = _psc_snapshot(monkeypatch, tmp_path) + corpus = local.fetch(_PSC) + assert asked == _psc_files() + # In the order of their lines, whose keys are sorted: by forename. + assert corpus.records == [ + {"name": "Mr Ann Lee", "title": "Mr", "forename": "Ann", "surname": "Lee", + "nationality": "British"}, + {"name": "Mr Bo Wu", "title": "Mr", "forename": "Bo", "surname": "Wu", + "nationality": "British"}, + {"name": "Mr Cy Doe", "title": "Mr", "forename": "Cy", "surname": "Doe"}, + {"name": "Dr Di Fox Roe", "title": "Dr", "forename": "Di", "middle_name": "Fox", + "surname": "Roe", "nationality": "Irish"}, + ] + assert corpus.meta["data_date"] == _PSC_DAY + assert corpus.meta["detail"] == { + "parts": 2, "sample": "1 in 1", "lines": 9, local.PSC_KINDS[0]: 6, local.PSC_KINDS[1]: 1, + "records_of_other_kinds": 2, "individuals_without_a_nationality": 1, + "sampled_names_that_are_empty": 1} + assert corpus.header().endswith("4 filed names (local corpus psc-individuals)") + + +def test_a_psc_fetch_writes_no_field_but_the_name_its_boxes_and_the_nationality( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _psc_snapshot(monkeypatch, tmp_path) + local.fetch(_PSC) + folder = tmp_path / "local" / _PSC + kept = set(local.PSC_KEPT) + for path in [folder / "raw.jsonl", *(folder / "parts").iterdir()]: + text = (gzip.decompress(path.read_bytes()) if path.suffix == ".gz" + else path.read_bytes()).decode() + for leaked in ("Invented Row", "ZZ1", "1970", "England", "00000001", + "ownership", "Invented Holdings"): + assert leaked not in text, (path.name, leaked) + for line in text.splitlines(): + row = json.loads(line) + assert "part" in row or set(row) <= kept, (path.name, row) + + +def test_a_psc_sample_keeps_the_same_records_whatever_part_files_them( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + people = [_psc_person(f"Ann Lee{n}") for n in range(40)] + _psc_snapshot(monkeypatch, tmp_path, {1: _psc_lines(*people[:25]), + 2: _psc_lines(*people[25:])}) + monkeypatch.setattr(local, "PSC_SAMPLE", 4) + corpus = local.fetch(_PSC) + kept = corpus.records + assert 0 < len(kept) < 20 and corpus.meta["detail"]["sample"] == "1 in 4" + assert all(local.psc_sampled(_extract_line(r)) for r in kept) + # Filed by other parts, in another order, the same records are kept. + _psc_snapshot(monkeypatch, tmp_path, {1: _psc_lines(*people[30:]), + 2: _psc_lines(*reversed(people[:30]))}) + monkeypatch.setattr(local, "PSC_SAMPLE", 4) + shutil.rmtree(tmp_path / "local" / _PSC / "parts") + assert local.fetch(_PSC).records == kept + # The control: with every record kept, all 40 are there. + monkeypatch.setattr(local, "PSC_SAMPLE", 1) + assert len(local.fetch(_PSC).records) == 40 + + +def _extract_line(record: dict[str, str]) -> str: + """A record as its extract line, the text psc_sampled hashes.""" + return local.corpus_build.jsonl_line(record, sort_keys=True) + + +@pytest.mark.parametrize(("bad", "match"), [ + ({"data": {"kind": _PSC_PERSON, "name": "Mr Ann Lee"}}, + "1 of 2 individuals have no name_elements"), + (_psc_person("Mr Ann Lee", title="Mr", forename="Ann", surname="Lee"), + "1 of 2 individuals' display names are not their four boxes joined"), + ("{not json", "1 of 2 lines are not JSON"), +], ids=["no name_elements", "a name not its boxes joined", "a line not JSON"]) +def test_a_psc_snapshot_whose_layout_has_changed_is_refused( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + bad: dict[str, Any] | str, match: str) -> None: + good = _psc_person("Bo Wu") + _psc_snapshot(monkeypatch, tmp_path, {1: _psc_lines(bad, good)}) + with pytest.raises(ValueError, match=match): + local.fetch(_PSC) + assert local.load(_PSC) is None + # The control: past the threshold the same snapshot is read, the bad + # record counted and dropped, so the threshold is what refused it. + monkeypatch.setattr(local, "PSC_MAX_BAD", 1) + assert [r["name"] for r in local.fetch(_PSC).records] == ["Mr Bo Wu"] + + +@pytest.mark.parametrize(("page", "match"), [ + (b"
The snapshot has moved.
", "links no snapshot parts"), + (_psc_page(*_psc_files(3)[:2]), r"parts \[1, 2\] of \[3\]"), + (_psc_page(_psc_files()[0], _psc_files(day="2026-10-23")[1]), + r"dated \['2026-10-22', '2026-10-23'\]"), +], ids=["no parts", "a part missing", "parts of two days"]) +def test_a_psc_page_that_lists_no_whole_snapshot_is_refused( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, page: bytes, match: str) -> None: + asked = _serve_psc(monkeypatch, tmp_path, {}, page) + with pytest.raises(ValueError, match=match): + local.fetch(_PSC) + assert asked == [] + + +def test_a_psc_part_gone_mid_fetch_is_refused_and_the_next_run_resumes( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + files = _psc_files() + parts: dict[str, Answer] = {files[0]: _psc_part(1, _PSC_PARTS[1]), + files[1]: _gone(files[1])} + asked = _serve_psc(monkeypatch, tmp_path, parts) + with pytest.raises(ValueError, match="replaced during the fetch; fetch again"): + local.fetch(_PSC) + folder = tmp_path / "local" / _PSC + assert not (folder / "parts").exists() + assert sorted(p.name for p in (folder / "parts.new").iterdir()) == [ + local._extract_name(files[0])] + parts[files[1]] = _psc_part(2, _PSC_PARTS[2]) + assert len(local.fetch(_PSC).records) == 4 + assert asked == [*files, files[1]] # part 1 was not downloaded again + assert not (folder / "parts.new").exists() + + +def test_a_resumed_psc_fetch_drops_what_another_day_left( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + asked = _psc_snapshot(monkeypatch, tmp_path) + staging = tmp_path / "local" / _PSC / "parts.new" + staging.mkdir(parents=True) + stale = staging / "psc-snapshot-2026-10-21_1of2.jsonl.gz" + stale.write_bytes(gzip.compress(b'{"part": "yesterday", "counts": {}}\n')) + (staging / "psc-snapshot-2026-10-22_2of2.zip.part").write_bytes(b"cut") + assert len(local.fetch(_PSC).records) == 4 + assert asked == _psc_files() + assert sorted(p.name for p in (tmp_path / "local" / _PSC / "parts").iterdir()) == [ + local._extract_name(f) for f in _psc_files()] + + +def test_a_failed_psc_refresh_keeps_the_parts_and_the_corpus_it_had( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _psc_snapshot(monkeypatch, tmp_path) + before = local.fetch(_PSC) + newer = _psc_files(day="2026-10-23") + cut = _psc_part(2, _PSC_PARTS[2], day="2026-10-23") + _serve_psc(monkeypatch, tmp_path, {newer[0]: _psc_part(1, _PSC_PARTS[1], day="2026-10-23"), + newer[1]: cut[:-10]}) + with pytest.raises(ValueError, match="fetch again"): + local.main(["fetch", _PSC, "--refresh"]) + assert local.load(_PSC) == before + assert sorted(p.name for p in (tmp_path / "local" / _PSC / "parts").iterdir()) == [ + local._extract_name(f) for f in _psc_files()] + # The control: the same refresh, whole, replaces both. + _serve_psc(monkeypatch, tmp_path, {newer[0]: b"", newer[1]: cut}) + assert local.main(["fetch", _PSC, "--refresh"]) == 0 + after = local.load(_PSC) + assert after is not None and after.meta["data_date"] == "2026-10-23" + + +def test_a_psc_cache_missing_a_part_is_refused_with_the_way_out( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _psc_snapshot(monkeypatch, tmp_path) + local.fetch(_PSC) + (tmp_path / "local" / _PSC / "parts" / local._extract_name(_psc_files()[1]) + ).unlink() + with pytest.raises(ValueError, match=r"holds parts \[1\] of \[2\].*--refresh"): + local.fetch(_PSC) + + +@pytest.mark.parametrize(("body", "match"), [ + (b"not a zip at all", "is not a readable zip"), + (_psc_part(1, _PSC_PARTS[1], of=1, member="renamed.txt"), + "holds no psc-snapshot-2026-10-22_1of1.txt"), +], ids=["not a zip", "no member of its own name"]) +def test_a_psc_part_that_cannot_be_read_is_refused( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: bytes, match: str) -> None: + _serve_psc(monkeypatch, tmp_path, {_psc_files(1)[0]: body}) + with pytest.raises(ValueError, match=match): + local.fetch(_PSC) + assert not list((tmp_path / "local" / _PSC / "parts.new").glob("*.zip*")) + + +def _plant(tmp_path: Path, day: str, bodies: dict[int, bytes] = _PSC_PARTS, + folder: str = "parts") -> Path: + """The extracts of a snapshot of `bodies` dated `day`, written into + the cache's `folder` as renew_psc would write them; the folder.""" + where = tmp_path / "local" / _PSC / folder + where.mkdir(parents=True, exist_ok=True) + of = len(bodies) + for n, file in enumerate(_psc_files(of, day), 1): + zipped = tmp_path / file + zipped.write_bytes(_psc_part(n, bodies[n], of, day)) + (where / local._extract_name(file)).write_bytes( + local._psc_extract(zipped, file.replace(".zip", ".txt"))) + return where + + +def test_a_psc_cache_holding_parts_of_two_snapshots_is_refused_before_any_is_read( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _serve_psc(monkeypatch, tmp_path, {}) # no download is expected + monkeypatch.setattr(local, "PSC_MAX_BAD", 1) + parts = _plant(tmp_path, _PSC_DAY) + assert len(local.fetch(_PSC).records) == 4 # the control: one snapshot + _plant(tmp_path, "2026-10-21") + with pytest.raises(ValueError, match=r"holds part 1 twice.*--refresh"): + local.fetch(_PSC) + for path in parts.glob("*2026-10-21*"): + path.unlink() + (parts / local._extract_name(_psc_files(day="2026-10-21")[1])).write_bytes( + (parts / local._extract_name(_psc_files()[1])).read_bytes()) + (parts / local._extract_name(_psc_files()[1])).unlink() + with pytest.raises(ValueError, match=r"dated \['2026-10-21', '2026-10-22'\]"): + local.fetch(_PSC) + + +def test_a_psc_snapshot_whose_person_kind_was_renamed_is_refused( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + people = [_psc_person(f"Ann Lee{n}", kind="individual-person-renamed") for n in range(9)] + owner = _psc_person("Bo Wu", kind=local.PSC_KINDS[1]) + _psc_snapshot(monkeypatch, tmp_path, {1: _psc_lines(*people, owner)}) + with pytest.raises(ValueError, match="1 of 10 lines are .* records: a kind"): + local.fetch(_PSC) + # The control: under the kind's own name, the same snapshot is read. + monkeypatch.setattr(local, "PSC_KINDS", ("individual-person-renamed", local.PSC_KINDS[1])) + shutil.rmtree(tmp_path / "local" / _PSC / "parts") + assert len(local.fetch(_PSC).records) == 10 + + +@pytest.mark.parametrize(("damage", "match"), [ + (lambda body: body[:-10], "is not an extract this tool wrote"), + (lambda body: b"not gzip", "is not an extract this tool wrote"), + (lambda body: gzip.compress(b"{not json\n"), "is not an extract this tool wrote"), + (lambda body: gzip.compress( + gzip.decompress(body).replace(b"_1of2.txt", b"_2of2.txt", 1)), + "its header does not describe it"), + (lambda body: gzip.compress( + gzip.decompress(body).replace(b'"lines": 7', b'"lines": "7"', 1)), + "its header does not describe it"), +], ids=["cut short", "not gzip", "header not JSON", "another part's header", + "a count not a number"]) +def test_a_damaged_psc_extract_is_refused_naming_the_refresh( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + damage: Callable[[bytes], bytes], match: str) -> None: + _serve_psc(monkeypatch, tmp_path, {}) + monkeypatch.setattr(local, "PSC_MAX_BAD", 1) + parts = _plant(tmp_path, _PSC_DAY) + first = parts / local._extract_name(_psc_files()[0]) + assert b'"lines": 7' in gzip.decompress(first.read_bytes()) + first.write_bytes(damage(first.read_bytes())) + with pytest.raises(ValueError, match=f"{match}.*fetch {_PSC} --refresh"): + local.fetch(_PSC) + + +def test_a_psc_extract_of_another_version_is_neither_read_nor_resumed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + asked = _psc_snapshot(monkeypatch, tmp_path) + monkeypatch.setattr(local, "PSC_EXTRACT_VERSION", 0) + parts = _plant(tmp_path, _PSC_DAY) + monkeypatch.setattr(local, "PSC_EXTRACT_VERSION", 1) + with pytest.raises(ValueError, match="is not an extract of version 1"): + local.fetch(_PSC) + # Staged for a resume, the old version's extracts are downloaded anew. + os.replace(parts, parts.with_name("parts.new")) + assert len(local.fetch(_PSC).records) == 4 + assert asked == _psc_files() + + +def test_a_psc_corpus_of_another_sample_is_refused_on_load( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _psc_snapshot(monkeypatch, tmp_path) + local.fetch(_PSC) + assert local.load(_PSC) is not None # the control + monkeypatch.setattr(local, "PSC_SAMPLE", 8) + with pytest.raises(ValueError, match=r"keeps 1 in 1 filed names.*keeps 1 in 8" + f".*fetch {_PSC} --refresh"): + local.load(_PSC) + + +def test_a_psc_page_linking_each_part_twice_is_one_snapshot( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + files = _psc_files() + _serve_psc(monkeypatch, tmp_path, { + f: _psc_part(n, _PSC_PARTS[n]) for n, f in enumerate(files, 1)}, + page=_psc_page(*files, *files)) + assert len(local.fetch(_PSC).records) == 4 + + +def test_a_psc_fetch_cut_off_between_its_two_renames_resumes_without_downloading( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + asked = _serve_psc(monkeypatch, tmp_path, {}, _psc_page(*_psc_files())) # any download fails + _plant(tmp_path, "2026-10-21", folder="parts.old") + _plant(tmp_path, _PSC_DAY, folder="parts.new") + assert len(local.fetch(_PSC).records) == 4 + assert asked == [] + folder = tmp_path / "local" / _PSC + assert sorted(p.name for p in folder.iterdir() if p.is_dir()) == ["parts"] + + # -- load ---------------------------------------------------------------------- def _fetched(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tools/differential/README.md b/tools/differential/README.md index b983caad..4fbfac3f 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -233,12 +233,13 @@ uv run python tools/differential/build_shapes_corpus.py --coverage ## Local corpora -A local corpus holds real names that are not committed: fetched into the cache by `local_corpora.py`, read by `snapshot.py --local` and by a row of `tools/name_usage.py`, and seen by no reviewer and no CI run (the SURVEY job in mechanisms.md#NAME-DATA-JOBS, and WATCH outside CI). What reaches the repository is what a contributor cites from it, counts and shapes written with made-up names. +A local corpus holds real names that are not committed: fetched into the cache by `local_corpora.py`, read by `snapshot.py --local` and by a row of `tools/name_usage.py`, scored by `local_agreement.py` where its records carry their fields, and seen by no reviewer and no CI run (the SURVEY job in mechanisms.md#NAME-DATA-JOBS, and WATCH and SCORE outside CI). What reaches the repository is what a contributor cites from it, counts and shapes written with made-up names. ``` uv run python tools/differential/local_corpora.py fetch ctgov-officials uv run python tools/differential/local_corpora.py tally ctgov-officials --top 50 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: @@ -257,9 +258,14 @@ uv run python tools/differential/snapshot.py --local ctgov-officials | `tse-donors` | every individual donor to a candidate in Brazil's 2022 general and 2024 municipal elections (the TSE's candidates' accounts, rows from `Recursos de pessoas físicas`), a record per distinct pair of the name as the campaign filed it (`name`) and the federal tax registry's name for the same donor (`registry`): a typed form with its reference form beside it. Of 854,531 records fetched 2026-10-22 (848,351 distinct filed names), 205,925 differ from their registry name -- an agnomen dropped or abbreviated (`JR` filed for the registry's `JUNIOR` in 1,615, `JÚNIOR` for `JUNIOR` in 975), a word left out. Nearly all in capitals (845,498 filed names), in spoken order with no comma (184 records hold one), and full of particles and agnomens (`DE`, `DA`, `DOS`; `FILHO` ends 10,955 distinct filed names, `JUNIOR` 13,784, `NETO` 8,410). Published by the TSE under CC BY | case, mostly; the family-comma shape; credentials and titles, which a donation form has no place for; donors to parties rather than candidates; names outside Brazil; names in any script but Latin | | `tw-donors` | the distinct names of every individual donor in every campaign finance report Taiwan's Control Yuan publishes on its political donation platform (ardata.cy.gov.tw, whose zips data.gov.tw also lists): 454 reports of 64 elections on 2026-10-22, presidential, legislative and local: every report from 2018's elections on, and the residual reports of 2014's and 2016's. Each report's zip is cached under the name the platform lists it by, which carries the date it was built, so a refresh downloads only the reports published or rebuilt since. Names as a candidate's staff typed them into a filing, published in full where the ID, address and phone beside them are masked. Of the 308,667 names dated 2026-10-05, 296,820 were three Han characters, 5,449 four and 5,260 two; the population for unspaced Han names written family first, and for the married double surname (冠夫姓) a four-character name often is. Under the Open Government Data License, version 1.0, with the platform's own bar on using the data for profit, credit investigation or another improper purpose | names in spoken order; Latin script (80 of the names dated 2026-10-05 held a Latin letter); the comma (3 of them held a fullwidth one, none an ASCII one); case; names outside Taiwan; donors whose names the report does not publish, such as anonymous gifts; Japanese kanji names, which the Han script cannot tell from Chinese | | `ukec-individuals` | the distinct names of the individual donors in the UK Electoral Commission's register of donations (to parties, their members, third parties, permitted participants and regulated donees), from the search's CSV export under the site's own default filter, fetched whole on every fetch (9.5 MB on 2026-10-22, accepted dates from 2001 to 2026). The default leaves out donations reported only before a poll (`prePoll=false`: 374 rows and 21 names on 2026-10-22). A name as the donee's return gave it, mostly in its spoken order and mixed case, with the donor's title: of its 15,214 names on 2026-10-22, `parse()` read a title in 8,219 (its `title` field non-empty) (`Mr`, `Ms`, `Mrs`, `Lord`, `Dr`, `Sir`; 32 a run of two or more, `The Rt Hon`) and a suffix in 320 (`MP`, `OBE`, `CBE`, `QC`). The population for titled British names. The Commission publishes its documents under the Open Government Licence v3.0, but the register states no reuse terms of its own, which the credit line says | the family-comma shape (2 names hold a comma) and case (9 are in capitals); small donors, since a donation is reported only above a threshold that depends on the donee; names outside the UK; names in any script but Latin | +| `psc-individuals` | the people with significant control of UK companies, and the beneficial owners of overseas entities, from Companies House's daily snapshot (33 zipped parts of JSON lines, 2.44 GB on 2026-10-22): each person's display name, the four boxes the filer typed it into (title, forename, middle name, surname) and the stated nationality, nothing else. A record is a distinct filed name, kept one in eight by a hash of the record (1,040,708 of 8,322,939 on 2026-10-22), a size the machinery can hold in memory. The one local corpus whose names carry their fields, scored by `local_agreement.py` | names nobody filed into boxes, so almost no comma (838 of 1,040,708 on 2026-10-22), no credential run and almost no suffix; a display name that is not its boxes joined, which the fetch drops (2 of 14,025,738 individuals on 2026-10-22) | A live source has no fixed snapshot to pin, so the cached copy is the pin: `meta.json` holds the source's data date and the sha256 of `raw.jsonl` and `corpus.jsonl`, checked on every load, and every report, the `name_usage.py` row included, carries the source and that date. Quote a count with the date, and expect a later fetch to differ. A local snapshot records which fetch it was read from, and `snapshot.py --local` refuses to compare it with a corpus fetched since, which would report the names the source gained and lost as moves: after a `fetch --refresh`, record again from the base checkout. ClinicalTrials.gov's terms ask anyone redistributing its data to credit it, show NLM's processing date and say what was changed; keeping the names in the cache and citing counts with that header meets them. The FEC's bar on soliciting and commercial use is in the credit line of every `fec-individuals` report, so a count quoted from it carries the bar with it. A record there is a distinct name, not a contribution or a contributor: one donor gives many times (34,264,742 rows held 1,893,964 names in the file dated 2026-10-18), and counting contributions would weight a shape by how often its bearers give. `tools/name_usage.py` looks a word up in each corpus's word index, cached as `wordindex.json` beside it on first use and keyed to the records' sha256 and to the code that cuts and folds a word, so a refetch or a change to the fold rebuilds it; a single-word lookup it answers parses neither the records nor the names. Measured 2026-10-22 with both corpora fetched, a single-word `name_usage.py --no-wikidata` run took 2.5s with their indexes cached (12.5s before the cache, 14.8s on the run that builds it). A spaced word (`Ph. D.`) still scans every name, which took 8.6s, nearly all of it the FEC corpus's. `fetch --refresh` re-downloads the FEC's zip rather than re-reading the cached one, replacing it only once the new one is whole, and a corpus fetched for another cycle than `FEC_CYCLE` is refused on load. The `fec-individuals` fetch refuses a file whose lines stop matching the 21 columns it reads, or whose names stop holding a comma, rather than read it as a smaller or a different corpus. A `ukec-individuals` record is a distinct name too, not a donation or a `DonorId`: one name often holds several IDs, which seem to come with reports rather than with people (15,214 names held 24,473 on 2026-10-22, the IDs counted in `meta.json`'s detail; 2,663 names held several at one `RegulatedEntityId` alone, counted from the export and not kept, though some of those may be two people of one name), so a count of IDs would weight a name by how often it was reported. Its fetch reads the register's total from the JSON search under the same filter before and after the export, and refuses an export holding another number of rows, or a register that changed in between: the export ignores `rows=`, so the total is what tells a cut or capped export from the whole register. It refuses an export whose last row is cut short (Python's `csv` returns a body cut inside a row as a short last row, which the row count would otherwise take for a whole one), and a header other than the 29 columns `UKEC_COLUMNS` names, and counts into `meta.json`'s detail the rows it skips: rows not 29 fields, rows from a donor that is not an individual (the filter asks the server to leave those out), and individuals with no name. Past `UKEC_MAX_BAD` (1 in 10,000) of either of the first two, or `UKEC_MAX_NAMELESS` (1 in 1,000; 4 of 42,355 rows were nameless on 2026-10-22) of the third, it refuses the export. The data date is when the first search answered, in UTC: the register states no date of its own, so the counts above, dated by the day they were fetched (2026-10-22), carry the data date 2026-10-23T03:13:19Z in their header. `tw-donors` keeps one zip per report under `reports/`, whole, its masked ID, address and phone columns included, as the FEC's zip is kept: the platform's download answers a report that does not exist and a fault alike, with a 500 and an incident number, so the fetch reads which reports exist from the platform's listing and refuses a listed report it cannot download. It reads each file's columns by name, since a 2020 report lacks a column the newest ones write, and refuses a column it does not know, a category it does not know, a row whose serial column names another report (the zip is not the one asked for), a listing that changes during the fetch, and a report more than half of whose rows it reads past. It reads back the rows a stray quote took into one field (169 rows in 12 records of three reports on 2026-10-22, 8 of them two rows joined by two stray quotes), counts donor cells that hold no name (匿名, a dash) or a masked one, and caches a downloaded zip only once its rows are read, so a refused zip is asked for again. A zip the platform rebuilt under the name it had is not seen: delete `reports/` to download every report again. A layout check written from one surveyed file meets every other file only at fetch time: `tw-donors`' first three fetches were each refused partway, by a report with no income writing one row of dashes, by a 2020 report with 24 columns where the surveyed one had 25, and by a row with no category. Each refusal cost every download again until the source cached its zips, so a new source that downloads many files caches each one before its checks run. `tse-donors` caches nothing but the corpus: each election's national member (`receitas_candidatos_