Skip to content

Add a GND corpus: real names with their parts stated, scored like CLDR - #688

Merged
derek73 merged 4 commits into
masterfrom
feat/gnd-corpus
Oct 16, 2026
Merged

derek73 merged 4 commits into
masterfrom
feat/gnd-corpus

Conversation

@derek73

@derek73 derek73 commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Summary

CLDR's person-name data (#654) gave the harness its first ground truth, but its names are fictional. This adds a second labeled source of real people: the German National Library's authority file (GND), read through lobid-gnd.

GND stores each person's preferred name in parts:

  • surname, forename, and prefix (the particle);
  • counting, a regnal number;
  • or a single personalName, for someone entered under the whole name with no surname (Icelanders, Malays, rulers, saints).

A corpus survey on 2026-10-16 ranked GND first among open sources, for three reasons:

  • License: CC0 with no conditions. DNB's own page says the GND "is available free of charge for general use under Creative Commons Zero (CC0 1.0) terms".
  • Native fields: the name parts are stored separately, so nothing has to be parsed out of a string.
  • Surname or not: its personalName/surname split answers whether a person has a family name at all.

What's in it

  • build_gnd_corpus.py samples only people who are deceased and have a Wikipedia article, which keeps the corpus to public figures.
    • Size: 801 renderings of 528 names.
    • Index form: each preferred name as cataloged (Gogh, Vincent van).
    • Composed form: outside family-first areas, also a given-first form built from the fields (Vincent van Gogh), flagged composed: true.
    • Sampling: by shape and area, never at random. Each stratum takes a deterministic id slice, in sha256 order.
    • Stops before writing when a stratum falls below its floor, a query matches nothing, or a pool pages short, so a change in lobid fails the build instead of shrinking the corpus.
    • Cache: raw responses are cached outside the tree ($NAMEPARSER_TOOLS_CACHE/gnd), as tools/name_usage.py does. Each cache file is validated on read and written atomically. Only the derived rows are committed, with gnd_LICENSE as the credit.
  • cldr_agreement.py now takes more than one source (sources()).
    • gnd_fields.jsonl is written in CLDR's field vocabulary, so the existing comparison rules apply unchanged.
    • GND gets its own record (gnd_agreement.json), held by the same ratchet test, now parametrized per source.
    • --record prints, per source, how many changes of each kind it accepts.
    • The locale view still reads CLDR only.
  • Only what GND states is compared.
    • The particle split is compared only where a prefix stands before a one-word surname.
    • Otherwise the family is stated whole: GND does not split a particle inside a surname (Vélez de Guevara, Zuylen van Nijevelt), so a missing split means "not stated", not "no particle".
    • A glued particle (d'Arenberg) is stated whole in its composed line.
    • A test holds that every field occurs in its rendering's text.
  • snapshot.jsonl gains 797 names, and no existing reading moves.

Agreement, measured 2026-10-16

Role Agree
title 800/801
given_names 599/801
family 580/801
suffix 777/801
family_particles 218/259
family_base 258/259

579 of the 801 renderings agree in every role. Per stratum:

Stratum Renderings agreeing in every role
plain-anglo, plain-europe 50/50, 50/50
index-cjk, index-hu 40/40, 20/20
particle-de-at, nl-be, fr, iberia, it 59/60, 58/60, 47/50, 49/50, 36/40
particle-elided, particle-arab 12/26, 20/40
compound-iberia, compound-latam 42/60, 38/60
regnal 17/40
direct-is, my-id, arab, ir-af, et-mm, eu 0/30, 10/30, 4/30, 3/20, 2/15, 22/30

What the record says (the worklist)

  • Icelandic patronymics and Malay names are given a family name. Kristján Eldjárn reads family Eldjárn. This is the "family may be empty" question 3.0 is weighing.
  • Spanish and Latin American compound surnames read as middle + family. Josep Domènech Estapà. This bears on surname2.
  • Regnal numbers read as a family name. Sancho I. reads family I., while Johann II. reads it as a suffix.
  • Elided and Arabic particles are not split off. Arenberg, Auguste Marie Raymond d' and Ǧaʿfarī, Ibrāhīm al-.

What it is blind to (also in the docstring and the README row)

  • GND writes names in German library romanization and gives historic figures their German forms. Latin script only.
  • Titles, credentials and nicknames are kept out of preferred names, apart from a few inside a personalName, such as Imam Bonjol.
  • Cataloging rules decide what counts as a surname, not the bearer's culture. Vietnamese and Thai names are entered as a personalName, so no stratum samples those areas.
  • A stratum's count reflects what was sampled, not how common the shape is.

Review rounds

/pr-review-toolkit:review-pr ran the code, test, comment and silent-failure reviewers. The fixes are in 45dc2a8:

  • Unstated particles: 19 renderings recorded the parser's de inside a GND surname as a disagreement no fix could clear. Such surnames are now stated whole.
  • Loud failures: the build now stops instead of shrinking the corpus, and the cache is validated.
  • Guards: the regnal branch checks the preferred name, and a record with an unknown field is refused.
  • Tests: each guard is isolated, and pool, build and cache have offline tests. Six mutants of the new guards are each killed.
  • Docs: the license quote is now verbatim.

A second review of that fix commit found the same unstated-particle gap one shape over, inside a several-word core after a prefix (Zuylen van Nijevelt, Julius van). That is fixed in 1de7ee0, and its revert is killed by the new test. The README's new "Adding a source" note records both misses for the next source.

Release figures (decided)

A release figure counts GND's moved names in, its composed renderings included, because the names are real (Derek, 2026-10-16). AGENTS.md's rule that counts CLDR's fictional renderings out is unchanged. The decision is recorded in tools/differential/README.md.

Test plan

  • uv run pytest: 18,079 passed
  • uv run mypy, uv run ruff check
  • The negative controls for the new guards each fail their test when reverted (mutated in place, then restored from git).
  • The re-record test redirects every source's record through sources(), so it cannot write into the tree, and it checks that every record was rewritten.

🤖 Generated with Claude Code

CLDR's person-name data gave the harness its first ground truth, but its
names are fictional. The German National Library's authority file (GND,
CC0) stores every person's preferred name in parts -- surname, forename,
prefix, counting, or a personalName for someone entered under the whole
name with no surname -- so it can be scored role by role the same way.

build_gnd_corpus.py samples deceased, Wikipedia-linked people (public
figures only) from lobid by shape and area: no-surname names from
Iceland, Malaysia/Indonesia, the Arab world, Iran, Ethiopia and Europe;
regnal names; particles by area, plus elided (d') and Arabic (al-) ones;
Spanish and Latin American compound surnames; CJK and Hungarian index
forms; and a plain control slice. 801 renderings of 528 names: each
preferred name as cataloged, plus a given-first form composed from its
fields outside family-first areas, flagged composed. Raw responses are
cached outside the tree, like tools/name_usage.py's.

The fields are written in CLDR's vocabulary, so cldr_agreement.py
compares them by its existing rules as a second source, with its own
record (gnd_agreement.json) and the same ratchet test. 559 of the 801
renderings agree in every role. The 242 that do not are a worklist:
Icelandic and Malay names given a family, Spanish compound surnames
read as middle plus family, regnal numbers read as a family name
(`Sancho I.`), elided and Arabic particles not split off.

The snapshot gains 797 names and no existing reading moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.03%. Comparing base (1a4e8a1) to head (fd926d2).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #688   +/-   ##
=======================================
  Coverage   99.03%   99.03%           
=======================================
  Files          46       46           
  Lines        4541     4541           
=======================================
  Hits         4497     4497           
  Misses         44       44           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

A GND surname with no prefix was written as surname-core, which tells the
comparison the name has no particle. GND does not split a particle inside
a surname (Vélez de Guevara), so 19 renderings recorded the parser's
`de` as a disagreement no fix could clear. Such a surname is now stated
whole, and only the family is compared: the GND record drops 12
renderings and 71 particle/base entries, and gains none.

The build now fails before writing instead of shrinking the corpus: a
stratum below its floor, a query that matches nothing, or pages that do
not add up to the total. A cached response is validated on read and
named when damaged, and written atomically. The regnal branch checks its
preferred name, and a record carrying a field the script does not know
is refused. --record prints, per source, how many changes of each kind
it accepts, and an empty fields file is an error.

Tests: every rejection guard isolated, the glued separators
parametrized, pool() and build() on a stubbed search, the cache, the
committed files' invariants, and the re-record test redirected through
sources() so a later source cannot be written into the tree. Six
mutants of the new guards are each killed.

Docs: the license quote made verbatim, the README's _CORPUS_NAMES
sentence rewritten (the release-figure rule is AGENTS.md's to state, and
this PR leaves it alone), CLDR-only wording generalized, an "adding a
source" lesson in the README, and AGENTS.md's validation paragraph now
names the GND record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derek73 derek73 added this to the 2.5 milestone Oct 16, 2026
derek73 and others added 2 commits October 16, 2026 14:36
The second review of the fix found its rule ("what GND leaves unsplit is
not stated") broken one shape over. With a prefix before a core of
several words, the core can still hold an unstated particle:
`Zuylen van Nijevelt, Julius van` recorded the parser's second `van` as
a particle disagreement no fix could clear. A family is now stated whole
unless a prefix stands before a one-word core. The GND record drops 8
renderings and 18 entries, and gains none.

Also from that review: the README's blind-spot cell no longer says no
title reaches a preferred name (two do, inside a personalName), the
builder docstring lists `surname` in the field vocabulary, and the
refusal table's comment no longer claims every row isolates its guard.
The README's "adding a source" lesson names this second miss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Derek's call on #688's open question: the names are real, so they count,
composed renderings included; the AGENTS.md rule stays CLDR's alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derek73
derek73 merged commit fccaec8 into master Oct 16, 2026
12 checks passed
derek73 added a commit that referenced this pull request Oct 16, 2026
…ir text

The GND review (#688, 1de7ee0) found the comparison reads a missing
field as an empty role. The alignment makes that sound here by
claiming every word; these checks hold the committed file to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derek73
derek73 deleted the feat/gnd-corpus branch October 16, 2026 21:55
derek73 added a commit that referenced this pull request Oct 17, 2026
…igned (#689)

* Add a Wikidata corpus: second surnames and patronymics, token-aligned

Wikidata's P1950 (second family name in a Spanish name) and P5056
(patronym or matronym) are the only open labels for the two fields 3.0
is weighing. build_wikidata_corpus.py samples public figures (deceased,
or 10+ sitelinks) one country and one property per WDQS query, in MD5
order of the QID, and keeps a written name (P1559 or the own-language
label) only where every word aligns with exactly one stated part.

cldr_agreement.py gains the source, and PENDING_ROLES: surname2 is
scored inside family and patronymic inside given_names until
ParsedName has those attributes, then on its own. Derek chose that,
the given_names placement (a patronymic is not a family name) and
connectives joining the part after them. The CLDR, GND and locale
records do not move; the snapshot gains 668 names, none moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say the pending fields stay in their merged roles; eight citizenships, not six

Review: the merge in expected_roles never ends, and the comments said
it would. It is kept on purpose (whether family drops surname2 is 3.0's
design), so the comments now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold the committed Wikidata fields to occurring in, and covering, their text

The GND review (#688, 1de7ee0) found the comparison reads a missing
field as an empty role. The alignment makes that sound here by
claiming every word; these checks hold the committed file to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fail the Wikidata build loudly; pin the alignment's decisions

Review of #689:
- strata get floors, and build() raises before writing when one falls
  short (Malaysia's floor is 8: its pool held 17 on 2026-10-16);
- responses are shape-checked before caching (an API error arrives as
  HTTP 200), cached atomically, and a damaged cache file is named;
- a query matching nothing and an item the API leaves out or reports
  missing both raise;
- the pending-role gate skips only PENDING_ROLES, now a map to the role
  each is scored inside, which the summary prints;
- Malay Bin/Binti capitalized are connectives; _REJECT, which pinned
  nothing, is gone;
- tests for every decision a mutation showed unpinned, the build, the
  cache, and the committed strata; doc wording fixed, and the README
  no longer stretches the GND release-figure attribution to Wikidata.

The rebuilt corpus is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a person whose name-part item is deleted, rather than stopping the build

Second review of #689: the missing-item raise covered part items too,
so one deleted item in any pool would block every rebuild with advice
(--refresh) that cannot fix it. A missing pooled person still raises.
Also: the summary asks got_roles itself whether a pending field is
answered, Malaysia's floor leaves 3 names of slack, the README's
Malaysian count says 'matched the query', and the lesson is recorded
in the README's Adding a source paragraph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Simplify the Wikidata corpus code: derived merge, align helpers, shared stub

/simplify over #689:
- expected_roles derives the pending merge from PENDING_ROLES instead of
  spelling surname2 and patronymic three times;
- align() splits span search and the widest-span reduction into
  helpers, and says why the connective pass runs right to left;
- the missing-person check moves to build(), so entities() has one
  contract and no flag;
- the urlopen stub both build tests copied is shared in
  _differential_fixtures; the field-occurs check is one test over every
  stated-field source; the coverage check uses the public same().

Skipped: reverting the pending-role gate to 'role in got' (the last
review asked for the loud failure it gives), case-folding the Malay
connectives (would admit B. and Bt.), a per-language feminizer hook and
a shared shape constant across tools (one language; the build-helpers
follow-up). Corpus and every record byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant